diff --git a/.agents/notes/README.i18n.yaml b/.agents/notes/README.i18n.yaml index f92ea2f66d..faf4d69b5b 100644 --- a/.agents/notes/README.i18n.yaml +++ b/.agents/notes/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/README.md README.md: ae8e4724d610c97d74910d7dec5c95af69e93281 -README.zh.md: a16403ae87cd16d758b8303f42f60ad34b9c1eb3 +README.zh.md: 8cecc7068d9efc5e2ebb78d2301d03633f70f137 diff --git a/.agents/notes/README.zh.md b/.agents/notes/README.zh.md index a16403ae87..8cecc7068d 100644 --- a/.agents/notes/README.zh.md +++ b/.agents/notes/README.zh.md @@ -16,13 +16,13 @@ 文件名中的日期是该主题**首次提出**的时间(以 git 历史为准)。Agent Note 之间的交叉引用使用相对 Markdown 链接(`[topic](../../implemented/architecture/2026-…-….md)`),从不使用纯文字或编号,这样既可机械检查,也能在文件夹间移动时保持有效。 -活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 +活跃生命周期目录树就是工作清单:浏览其生命周期/类别文件夹,或搜索仓库即可。请勿添加集中式 `INDEX.md`;设计理由见[不设索引的 Agent Note](implemented/process/2026-07-19-remove-generated-agent-note-index.zh.md)。未来指导价值较低的已实施记录会移至下文所述、单独冻结的 [`archived/`](archived/AGENTS.md) 目录树。 ## 分类 -每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.md)。 +每份 Agent Note 属于 `scripts/agent-note-tree.ts` 中封闭集合里的一个路径编码类别;分类门禁拒绝其他文件夹。新增类别需要同时更新规范集合与本节。见[分类 Agent Note](implemented/process/2026-06-20-agent-note-classification.zh.md)。 | 类别 | 覆盖范围 | |---|---| @@ -41,7 +41,9 @@ 归档路径编码为 `archived/{class}/yyyy-mm-dd-topic-title.md`;其中有意省略 `implemented`,因为只有 implemented Agent Note 可以进入归档。归档变更会移动完整的英文、中文和伴随记录三个文件,保留 `Status: implemented`,在两种语言的文件中紧接该状态行插入相同的 `Archived: YYYY-MM-DD` 行,重新记录伴随记录,并修复或删除入站链接。归档时只允许对内容做这些更改。 -封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.md) 记录了设计依据。 +封存后,每组归档文件都永久冻结。禁止编辑、翻译、重新格式化、更新、移动或删除,也不得将其视为当前行为的权威依据。文档门禁会跳过归档源文件,包括其中的出站链接;当活跃文档有意引用历史时,仍可链接到归档 Agent Note。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 强制执行封闭的类别目录树、完整的三文件配对、归档元数据、伴随记录 hash,以及仅追加的冻结内容 manifest(元数据清单)。[归档政策 Agent Note](implemented/process/2026-07-26-frozen-agent-note-archive.zh.md) 记录了设计依据。 + + ## 何时需要写一份 @@ -57,7 +59,7 @@ ## 文件格式 -每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.md)。归档记录保留封存时的格式,并增加上述归档日期行。 +每份活跃 Agent Note 遵循统一的文件内格式,由 `pnpm run verify-agent-note-format`([scripts/verify-agent-note-format.ts](../../scripts/verify-agent-note-format.ts),`doc-sync`(文档同步门禁)的一环)强制执行;该格式的设计动机及其否决的替代方案见[统一格式 Agent Note](implemented/process/2026-07-05-uniform-agent-note-format.zh.md)。归档记录保留封存时的格式,并增加上述归档日期行。 ### 头部块 @@ -126,4 +128,4 @@ Status: ### 中文对侧文件 -`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 +`.zh.md` 对侧文件按 [i18n 约定](../../docs/i18n/README.zh.md)逐章节与其英文对侧文件保持相同结构;机器检查的头部标记(`# Agent Note: ` 和 `Status:` 行)保持英文原样不翻译。格式门禁跳过 `.zh.md` 文件;配对门禁检查它们的一致性。 diff --git a/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.i18n.yaml new file mode 100644 index 0000000000..8b40d4a6bb --- /dev/null +++ b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.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-08-11-plugin-settings-tabs.md: 1f1701e000b891b4fc00bc666f603ee00bae50a7 +2026-08-11-plugin-settings-tabs.zh.md: f76a4a9e2317eb6603b48e1a7e451abdc55e00ff diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md rename to .agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.md index 96e4c48926..1f1701e000 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.md +++ b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.md @@ -1,6 +1,7 @@ # Agent Note: Feature-owned tabs in Plugins settings Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-plugin-settings-tabs.zh.md) diff --git a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.zh.md similarity index 99% rename from .agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md rename to .agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.zh.md index fa8f462640..f76a4a9e23 100644 --- a/.agents/notes/implemented/architecture/2026-08-11-plugin-settings-tabs.zh.md +++ b/.agents/notes/archived/architecture/2026-08-11-plugin-settings-tabs.zh.md @@ -1,6 +1,7 @@ # Agent Note: “插件”设置中的功能自有标签页 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-plugin-settings-tabs.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml new file mode 100644 index 0000000000..725855f6c3 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +2026-07-31-composer-text-layers-share-one-scrollport.md: eb50673bb5fac50e12b0325c22c67072e130efb6 +2026-07-31-composer-text-layers-share-one-scrollport.zh.md: f0af130d34682fcdfe145eb73b18187ca316c0d2 diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md rename to .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md index d01231f706..eb50673bb5 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md @@ -1,6 +1,7 @@ # Agent Note: The composer's two text layers share one scrollport Status: implemented +Archived: 2026-08-20 English | [中文](2026-07-31-composer-text-layers-share-one-scrollport.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md rename to .agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md index 3ce8cc05dc..f0af130d34 100644 --- a/.agents/notes/implemented/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md +++ b/.agents/notes/archived/bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md @@ -1,6 +1,7 @@ # Agent Note: composer 的两层文本共用同一个滚动容器 Status: implemented +Archived: 2026-08-20 [English](2026-07-31-composer-text-layers-share-one-scrollport.md) | 中文 @@ -24,7 +25,7 @@ composer 的文本由两层叠放绘制(见 [InputBar](../../../../packages/cl 于是浏览器在同一帧、同一个合成器上,把同一个偏移施加给两层。光标与字形的绑定来自结构本身,而不是来自持续维护:没有代码要跑,没有事件要等,也没有任何状态可能落后一帧。滚轮接力处理器保留,只是从 textarea 改挂到滚动容器上,并且仍是这个盒子上唯一的监听。 -Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的删除可能在镜像层收缩后仍保留原先的行布局。[Safari 软换行恢复](2026-08-13-safari-textarea-soft-wrap-reflow.md)会在绘制前恢复零溢出不变量,而不改变单滚动容器设计。 +Safari 的原生文本控件存在一个引擎例外:跨过软换行阈值的删除可能在镜像层收缩后仍保留原先的行布局。[Safari 软换行恢复](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md)会在绘制前恢复零溢出不变量,而不改变单滚动容器设计。 上一版机制所需要的两样东西随它一起消失: diff --git a/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml new file mode 100644 index 0000000000..4567896a25 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.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-08-04-large-history-pagination-call-stack.md: 12e9bbf72c2eea2058bf83fe864e09b4a520d391 +2026-08-04-large-history-pagination-call-stack.zh.md: 288687b05ecdfc2858b4d67e1be199135ea20227 diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md rename to .agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.md index 28c2212112..12e9bbf72c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.md @@ -1,6 +1,7 @@ # Agent Note: Large history provenance is scanned without argument expansion Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-04-large-history-pagination-call-stack.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md similarity index 98% rename from .agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md rename to .agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md index 57dde9bdc0..288687b05e 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md @@ -1,6 +1,7 @@ # Agent Note: 大规模历史记录的溯源信息通过扫描处理,不做参数展开 Status: implemented +Archived: 2026-08-22 [English](2026-08-04-large-history-pagination-call-stack.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml new file mode 100644 index 0000000000..a6305c1f5d --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.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-08-06-plan-narrow-viewport-regression.md: c42a110bf487ffab8f5975e65a8eb9bff4f1b0ae +2026-08-06-plan-narrow-viewport-regression.zh.md: 3df4f8aca57a1830d7f8062cefe76ac1afa9c2ce diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md rename to .agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.md index 945d014e0c..c42a110bf4 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.md +++ b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.md @@ -1,6 +1,7 @@ # Agent Note: narrow-viewport plan chip click-area regression test Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-06-plan-narrow-viewport-regression.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md similarity index 96% rename from .agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md rename to .agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md index 3706012936..3df4f8aca5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md @@ -1,6 +1,7 @@ # Agent Note: 窄视口下 Plan chip 点击区域回归测试 Status: implemented +Archived: 2026-08-22 [English](2026-08-06-plan-narrow-viewport-regression.md) | 中文 @@ -8,7 +9,7 @@ Status: implemented 外部报告 dsh-external/issues#107(内部聚类为 deepseek-harness#1406)测得视口宽度在 760px 到 850px 之间时 Plan 控件与模型选择器发生重叠,模型选择器覆盖 Plan 控件的点击区域,导致在 800×720 下无法用鼠标退出 Plan 模式。其验收清单要求增加浏览器回归测试,断言 Plan 中心命中 Plan 按钮。 -浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,[web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.md)),但该行没有换行,重叠在两次重构后依然存在。 +浏览器回归测试在当前 master 上复现了报告:800×720 下 Plan chip 与模型 trigger 重叠 36.9px,chip 中心命中 trigger 的 label。composer 控制行是 `display: flex; justify-content: space-between` 且 `.trailing { flex: none }`:当控件总宽超过卡片时,可收缩的 `.tools` 组把流内子项留在 `min-width: 0` 的盒内,于是 chip——溢出前最后一个流内子项——被绘制到 trailing 组上方。报告以来 Plan 控件形态已变(select → chip,`c20b988166`/`fe91919346`),控制行也获得过自适应能力(`c8c75ec891`,[web-composer-shared-width-axis](../feature/2026-08-04-web-composer-shared-width-axis.zh.md)),但该行没有换行,重叠在两次重构后依然存在。 ## 决策 diff --git a/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml new file mode 100644 index 0000000000..644cd813ce --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.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-08-11-preset-card-description-clamp.md: b9088b46184cde6f000fa39afbfe2d1145137b24 +2026-08-11-preset-card-description-clamp.zh.md: a7a3c4f26a55405715fe017ec3a7deb164432b0e diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md rename to .agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.md index 16ebf371d5..b9088b4618 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md +++ b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.md @@ -1,6 +1,7 @@ # Agent Note: Preset cards clamp their description instead of sizing the roster Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-preset-card-description-clamp.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.zh.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md rename to .agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.zh.md index 5b7a18f41e..a7a3c4f26a 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-11-preset-card-description-clamp.zh.md @@ -1,6 +1,7 @@ # Agent Note: 预设卡片截断自身描述,而不是由描述决定整份名单的高度 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-preset-card-description-clamp.md) | 中文 diff --git a/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml new file mode 100644 index 0000000000..804fdb4fb7 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md +2026-08-13-safari-textarea-soft-wrap-reflow.md: 45cb3f39c50c72b44b8ae952ce3a861210e9f00a +2026-08-13-safari-textarea-soft-wrap-reflow.zh.md: 37409b010c0009edf3c944076ba8c3db1b140de9 diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md similarity index 99% rename from .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md rename to .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md index fb264a8e6f..45cb3f39c5 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md +++ b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md @@ -1,6 +1,7 @@ # Agent Note: Safari textarea soft-wrap shrink recovery Status: implemented +Archived: 2026-08-20 English | [中文](2026-08-13-safari-textarea-soft-wrap-reflow.zh.md) diff --git a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md similarity index 96% rename from .agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md rename to .agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md index 7f55a5260e..37409b010c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md +++ b/.agents/notes/archived/bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md @@ -1,12 +1,13 @@ # Agent Note: Safari textarea 软换行收缩恢复 Status: implemented +Archived: 2026-08-20 [English](2026-08-13-safari-textarea-soft-wrap-reflow.md) | 中文 ## 问题 -composer 把光标与选区留在透明的原生 textarea 中,由 backdrop 绘制可见字形,并由隐藏的镜像层决定完整草稿高度。因此,[单滚动容器决策](2026-07-31-composer-text-layers-share-one-scrollport.md)依赖 textarea 不持有可滚动溢出:每次草稿提交后,它的 `scrollHeight` 与 `clientHeight` 相等,`scrollTop` 为零。 +composer 把光标与选区留在透明的原生 textarea 中,由 backdrop 绘制可见字形,并由隐藏的镜像层决定完整草稿高度。因此,[单滚动容器决策](2026-07-31-composer-text-layers-share-one-scrollport.zh.md)依赖 textarea 不持有可滚动溢出:每次草稿提交后,它的 `scrollHeight` 与 `clientHeight` 相等,`scrollTop` 为零。 当 Backspace 让草稿跨过软换行阈值,同时 React 更新镜像层时,Safari 26.5.2 可能保留 textarea 原先的原生行布局。在复现出的两行变一行转换中,镜像层、backdrop、自增高栈和 textarea 盒都变为 28px 高,但 textarea 仍报告 `scrollHeight=52` 与 `scrollTop=20`。光标留在陈旧的原生行中,而 backdrop 已正确绘制为一行。 diff --git a/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml new file mode 100644 index 0000000000..0e622c3944 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-composer-edit-range-from-selection.md +2026-08-20-composer-edit-range-from-selection.md: 46eaa0add61bdab9fdcb4fcfd0ec08b44481126d +2026-08-20-composer-edit-range-from-selection.zh.md: 73a3903ad6c7c0aad55a35aacc5e1396084b6e7a diff --git a/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md new file mode 100644 index 0000000000..46eaa0add6 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.md @@ -0,0 +1,48 @@ +# Agent Note: Composer edits carry the range they applied to + +Status: implemented +Archived: 2026-08-20 + +English | [中文](2026-08-20-composer-edit-range-from-selection.zh.md) + +## Problem + +The input machine keeps its reference occurrences aligned by reconciling them against one edit range: entries before the range shift, entries after it hold, and an entry the range intersects loses its structured identity and stays behind as ordinary draft text. That last rule is the deliberate meaning of editing inside a reference. + +Ordinary typing supplied no range. A controlled textarea's change event carries only the resulting string, so the machine recovered the range by scanning the two drafts for a common prefix and suffix. That recovery is ambiguous whenever the inserted text repeats the text it lands against, and the greedy scan always resolves the ambiguity the same way: it slides the edit as late as the characters allow. + +A reference renders as `@` followed by its label, so typing `@` immediately before one produces exactly that collision. The user inserts at the reference's own offset; the scan reports an insertion one character later, inside the reference; reconcile applies the intersect rule and drops the occurrence. Deleting a character in front of such a reference slides the same way. + +The draft then still reads correctly to the eye while carrying no structured reference, and submission takes the occurrence-free path that sends the draft verbatim. The host receives the human-facing label instead of the owner's model form and resolves nothing. The serialization guard that exists to prevent exactly this downgrade never runs, because it only fires when an occurrence survives to be serialized. + +This became reachable when references [became literal inline text](../feature/2026-07-27-web-file-and-session-references.md). A reference previously occupied one `U+FFFC`, a character no keystroke produces, so the scan had nothing to collide with. + +## Decision + +`InputBar` records the textarea's selection and `inputType` during `beforeinput` and passes the resulting range to `setDraft`, which the machine already accepts and prefers over its own scan. A textarea exposes the edit no other way — `getTargetRanges()` is empty for form controls. + +An edit that replaces a selection reports that selection, and it is the range outright; the inserted length is whatever the draft grew by once the replaced range is accounted for. A caret delete replaces nothing and reports the bare caret, so its range comes from the direction `inputType` names and the number of characters the draft actually lost. The count is measured rather than assumed to be one, because a single caret gesture removes a multi-unit grapheme, a word, or a line just as readily. Chromium, WebKit, and Firefox all report the collapsed caret for `deleteContentBackward` and `deleteContentForward`, and all three derive the same range from it. + +Only the `insert` and `delete` families are recorded. A history replay reports wherever the caret happens to sit, which would survive every check while naming the wrong span; ignoring it leaves that path on the scan. + +The record is consumed once and cleared. A record whose draft length disagrees with the draft the change reports, a selection past the draft, a shrinking edit over a selection the caret cannot explain, or an undirected delete all yield no range, and the machine falls back to its scan. Paste and the boundary Backspace and Delete gestures already supplied their own ranges and are untouched. + +## Testing + +Component tests cover the trigger character typed in front of a reference, a caret Backspace, a caret Delete, a caret word delete, and a delete over a selection, asserting in each case that the occurrence survives at the shifted offset. The caret cases fail against the scan-recovered range, and the word case fails against a fixed one-character step. + +An assembled browser scenario drives the same gestures as real key presses against the shipped composition, which is the only place the range an engine reports for them can be observed; its golden projects the backdrop's segments, since the decoration layer is aria-hidden and the accessibility tree cannot see the chip. A real composition driven through the browser's IME path reports the composing segment as the selection at every intermediate state, and the reference survives each one. + +## Alternatives considered + +**Disambiguate the scan with the post-edit caret.** The caret pins which of the textually equivalent readings happened, and the change event already carries it. Rejected because it keeps a reconstruction where an exact fact is available, and it cannot separate the deleted and inserted halves of a replaced selection at all. + +**Give references a leading marker no keystroke produces.** A private-use character in place of the literal `@` removes the collision at the representation level, and the reject list for pasted text already names that range. Rejected because it re-adds a character that every serialization, selection, and accessibility path has to strip, to buy what an exact range buys directly, and it would leave ordinary typing reconstructing its range for every other reason. + +**Widen reconcile to keep an occurrence when the range only touches its edge.** Rejected because the misattributed offset lands strictly inside the reference, not on its boundary, so the rule change would not reach this defect while making the intersect rule vaguer. + +## Consequences + +Every native textarea edit that reaches `onChange` now names the range it applied to, so occurrence offsets follow the edit that actually happened rather than one merely consistent with the resulting characters. Draft writes that originate in the facade rather than the DOM — `insertText` and the command-token splices among them — still carry no range and keep the scan, as do the fallbacks above. + +The composer now depends on `beforeinput` preceding each value change, and on `inputType` naming the direction of a caret delete. Any future edit path that mutates the value without either silently returns to the scan rather than breaking, which keeps the failure mode the old behavior instead of a wrong range. diff --git a/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md new file mode 100644 index 0000000000..73a3903ad6 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md @@ -0,0 +1,48 @@ +# Agent Note: 输入框的编辑自带它所作用的范围 + +Status: implemented +Archived: 2026-08-20 + +[English](2026-08-20-composer-edit-range-from-selection.md) | 中文 + +## 问题 + +输入机器靠一个编辑范围来对齐引用 occurrence:范围之前的条目右移,之后的条目不动,被范围相交的条目失去结构化身份、以普通草稿文本留在原地。最后一条是"在引用内部编辑"的刻意含义。 + +普通打字不提供范围。受控 textarea 的 change 事件只带结果字符串,于是机器靠扫描两份草稿的公共前后缀来还原范围。只要插入的文本与它落点处的文本重复,这个还原就是有歧义的,而贪心扫描永远以同一种方式消解歧义:把编辑尽量往后滑。 + +引用渲染为 `@` 加标签,所以紧挨着引用前面打一个 `@` 恰好构成这种撞车。用户在引用自身的偏移处插入;扫描报告的插入位置晚一个字符,落在引用内部;reconcile 执行相交规则,删掉这个 occurrence。删除这类引用前面的一个字符会以同样方式滑动。 + +此时草稿看上去仍然正确,却已不携带任何结构化引用,提交走的是无 occurrence 的那条路,把草稿原样发出。宿主收到的是给人看的标签而不是所有者的模型形式,什么也解析不出来。专为阻止这种降级而存在的序列化守卫从不运行,因为它只在还有 occurrence 需要序列化时才触发。 + +这条路径是在引用[变成字面内联文本](../feature/2026-07-27-web-file-and-session-references.md)之后才可达的。此前一个引用占据一个 `U+FFFC`——任何按键都打不出的字符,扫描无从撞车。 + +## 决策 + +`InputBar` 在 `beforeinput` 期间记录 textarea 的 selection 与 `inputType`,并把由此得到的范围传给 `setDraft`;机器本就接受该参数,并优先于自身的扫描。textarea 没有别的途径暴露这次编辑——`getTargetRanges()` 对表单控件返回空。 + +替换一段选区的编辑会报告那段 selection,它直接就是范围;插入长度是扣除被替换范围后草稿增长的量。折叠光标的删除不替换任何东西、只报告光标本身,因此它的范围来自 `inputType` 指明的方向加上草稿实际减少的字符数。这个数量是**测量**得到而非假定为 1,因为一次折叠手势同样可能删掉一个多码元字形、一个词或一整行。Chromium、WebKit 与 Firefox 对 `deleteContentBackward` 和 `deleteContentForward` 都报告折叠光标,三者由此推导出相同的范围。 + +只有 `insert` 与 `delete` 两族会被记录。历史回放报告的是光标当时碰巧所在的位置,那会通过全部校验却指向错误区间;忽略它即让该路径留在扫描上。 + +记录只消费一次即清空。记录的草稿长度与 change 报告的草稿不符、selection 越过草稿末尾、选区之上出现光标无法解释的收缩、以及方向不明的删除,这几种情况都不产出范围,机器回落到扫描。粘贴以及边界处的 Backspace 与 Delete 手势本就自带范围,未受影响。 + +## 测试 + +组件测试覆盖在引用正前方输入触发字符、折叠 Backspace、折叠 Delete、折叠整词删除,以及选区替换式删除,每种都断言 occurrence 在右移后的偏移处存活。折叠类用例在扫描还原的范围下失败,整词用例在固定一字符步长的实现下失败。 + +组装层浏览器场景以真实按键对已发布组合驱动同样的手势——那是唯一能观察到引擎为这些手势报告何种范围的地方;其 golden 投影的是 backdrop 的分段,因为装饰层是 aria-hidden 的,无障碍树看不到 chip。经浏览器 IME 路径驱动的真实组合在每个中间态都把组合段报告为 selection,引用在每一步都存活。 + +## 备选方案 + +**用编辑后的光标位置消解扫描的歧义。** 光标能钉住若干文本等价读法中真正发生的那一种,而 change 事件本就携带它。拒绝:在已有精确事实可用时仍保留一次重建,而且它根本无法把"替换一段选区"拆成删除与插入两半。 + +**给引用一个按键打不出的前导标记。** 用私有区字符取代字面 `@`,在表示层消除撞车,而粘贴文本的剔除名单本就点名了那个区段。拒绝:为了换取一个精确范围本就能直接换到的东西,却重新引入一个必须在所有序列化、选择和无障碍路径上剥离的字符,而且普通打字仍会因为其他原因继续重建自己的范围。 + +**放宽 reconcile,让范围只触及边缘时保留 occurrence。** 拒绝:误判出的偏移严格落在引用内部而非边界上,规则放宽够不到这个缺陷,只会让相交规则本身更含糊。 + +## 后果 + +每一次经 `onChange` 到达的原生 textarea 编辑现在都指明它作用的范围,因此 occurrence 偏移跟随的是真实发生的编辑,而不是某个仅仅与结果字符一致的编辑。源自 facade 而非 DOM 的草稿写入——`insertText` 和命令 token 的替换等——仍不带范围并保留扫描,上述各类回落同样如此。 + +输入框由此依赖 `beforeinput` 先于每次取值变更发生,并依赖 `inputType` 指明折叠删除的方向。未来任何绕过其中之一改写取值的编辑路径会静默回落到扫描而不是出错,也就是说失效模式退回旧行为,而不是一个错误的范围。 diff --git a/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml new file mode 100644 index 0000000000..95cb9c6827 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-20-composer-reference-decoration-keys.md +2026-08-20-composer-reference-decoration-keys.md: 316d45841c658d3d65246fb7425526b10e2f6bf3 +2026-08-20-composer-reference-decoration-keys.zh.md: 90ac7c8011bb7f7f45312c25ffccb7dbbbb70505 diff --git a/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md new file mode 100644 index 0000000000..316d45841c --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.md @@ -0,0 +1,40 @@ +# Agent Note: Composer reference decorations key by draft-order ordinal + +Status: implemented +Archived: 2026-08-20 + +English | [中文](2026-08-20-composer-reference-decoration-keys.zh.md) + +## Problem + +The composer backdrop renders the draft as an array of segments: plain strings, a leading claim-token mark, one element per structured reference, and one mark per plain-text reference range. React reconciles that array by key. + +Structured references carry an identity — the occurrence table mints an `occurrenceId` that survives every edit — so their chips key by it. Plain-text reference ranges have no such identity: `scanTextRefs` re-derives them from the draft on every render, and nothing outside that scan remembers a range between two keystrokes. + +Keying those ranges by their draft offset made the key change whenever earlier text changed length. React then treated the range as a different element, unmounted the mark with its nested spans and inline glyph, and mounted a replacement. Every character typed or deleted ahead of a reference rebuilt every reference after the caret, and the work grew with the reference count. [Directory-syntax ranges](../feature/2026-07-27-web-file-and-session-references.md) made that path routine: they match on `@path/` syntax without a lexicon, and each one renders an icon. + +## Decision + +A plain-text reference mark keys by its index in the offset-sorted `textRefs` list, computed where the boundary list is assembled so a skipped boundary cannot shift it. The scan already returns the ranges in draft order, so the ordinal names the render slot a range occupies, which is the only identity a scan-derived range has. + +Structured chips keep `occurrenceId`. The two key strategies differ because the two range kinds differ in identity, not by oversight: a range the occurrence table owns keeps its node across reordering, and a range only a scan knows keeps its node across offset shifts. + +A range that stops matching the scan still loses its decoration, because it disappears from `textRefs` and the ordinal it held no longer exists. + +## Testing + +A component test holds the mark element and its glyph, types a character ahead of the range, and asserts the same nodes are still mounted; it then edits the token out of match shape and asserts the decoration is gone. The test fails against an offset-derived key. + +## Alternatives considered + +**Key by the range text.** Rejected: duplicate references collide on one key, and editing inside a range changes its key, which reintroduces the remount this fixes. + +**Give scan-derived ranges an identity table.** Rejected: it adds mutable state whose only consumer is a render key, and the scan would have to diff against the previous draft to maintain it. An edit that breaks a match simply dropping the range on the next scan is what keeps `scanTextRefs` a pure derivation. + +**Drop the keys and let React match by position.** Rejected: React requires keys on elements inside an array, and the plain string segments between them already match by index, so an unkeyed element warns without changing the outcome. + +## Consequences + +Typing ahead of a reference updates text nodes only; the mark and its icon stay mounted. The backdrop's per-keystroke DOM work no longer scales with the number of references in the draft. + +Because the key names a position, inserting a reference ahead of existing ones reuses the earlier nodes with new content instead of re-creating them. That is correct for these marks, which hold no focus, selection, or animation state, and it is the condition any future decoration on this layer meets before it keys by ordinal. diff --git a/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md new file mode 100644 index 0000000000..90ac7c8011 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md @@ -0,0 +1,40 @@ +# Agent Note: 输入框引用装饰按草稿顺序序号取 key + +Status: implemented +Archived: 2026-08-20 + +[English](2026-08-20-composer-reference-decoration-keys.md) | 中文 + +## 问题 + +输入框 backdrop 把草稿渲染成一组片段:纯文本字符串、开头的 claim token 标记、每个结构化引用一个元素、每个纯文本引用范围一个标记。React 按 key 协调这个数组。 + +结构化引用带有身份——occurrence 表铸造的 `occurrenceId` 在任何编辑后都保持不变——因此它们的 chip 用它作 key。纯文本引用范围没有这种身份:`scanTextRefs` 在每次渲染时从草稿重新推导它们,扫描之外没有任何东西在两次按键之间记住某个范围。 + +用草稿偏移量给这些范围取 key,会让前面文本长度一变 key 就变。React 于是把该范围当作另一个元素,卸载带嵌套 span 和内联图标的标记,再挂载一个替代品。在引用前面输入或删除任意字符,都会重建光标之后的每一个引用,工作量随引用数量增长。[目录语法范围](../feature/2026-07-27-web-file-and-session-references.zh.md)让这条路径成为常态:它们按 `@path/` 语法匹配,不依赖 lexicon,而且每个都渲染一个图标。 + +## 决策 + +纯文本引用标记以它在按偏移排序的 `textRefs` 列表中的下标作 key,在组装 boundary 列表处计算,因此被跳过的 boundary 不会让它偏移。扫描本身已按草稿顺序返回范围,所以该序号命名的是范围占据的渲染槽位,而这正是扫描推导出的范围唯一拥有的身份。 + +结构化 chip 保留 `occurrenceId`。两种 key 策略不同,是因为两类范围的身份不同,而非疏漏:occurrence 表拥有的范围在重排后保住自己的节点,只有扫描知道的范围在偏移变化后保住自己的节点。 + +不再匹配扫描规则的范围仍然失去装饰,因为它从 `textRefs` 中消失,它占据的序号也不复存在。 + +## 测试 + +组件测试持有标记元素及其图标,在范围之前输入一个字符,断言仍是同一批节点;随后把 token 编辑成不再匹配的形态,断言装饰消失。该测试在偏移量 key 下失败。 + +## 备选方案 + +**按范围文本取 key。** 拒绝:重复引用会撞同一个 key,且在范围内部编辑会改变 key,重新引入本次修复消除的重挂载。 + +**为扫描推导的范围建立身份表。** 拒绝:这会引入唯一消费者是渲染 key 的可变状态,而且扫描必须与上一版草稿做 diff 才能维护它。破坏匹配的编辑在下一次扫描时直接丢掉该范围,正是这一点让 `scanTextRefs` 保持为纯推导。 + +**去掉 key,让 React 按位置匹配。** 拒绝:React 要求数组内的元素带 key,而它们之间的纯文本片段本就按下标匹配,因此无 key 元素只会告警,不改变结果。 + +## 后果 + +在引用之前输入只更新文本节点;标记及其图标保持挂载。backdrop 每次按键的 DOM 工作量不再随草稿中的引用数量增长。 + +由于 key 命名的是位置,在已有引用之前插入新引用会以新内容复用先前的节点,而不是重建它们。对这些不持有焦点、选择区或动画状态的标记而言这是正确的,这也是该图层上任何未来装饰按序号取 key 前需要满足的条件。 diff --git a/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml new file mode 100644 index 0000000000..b01341e7d2 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-24-system-prompt-section-order-ties.md +2026-08-24-system-prompt-section-order-ties.md: d92756e751e893b1d03b8892ef71ff9faac9d2c6 +2026-08-24-system-prompt-section-order-ties.zh.md: 4a822b7925a38feb254dbc534fc6153c76a93e19 diff --git a/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md new file mode 100644 index 0000000000..d92756e751 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.md @@ -0,0 +1,28 @@ +# Agent Note: Equal-order system-prompt sections render in activation order + +Status: implemented +Archived: 2026-08-25 + +English | [中文](2026-08-24-system-prompt-section-order-ties.zh.md) + +## Problem + +`SystemPromptRegistry` sorts sections by `order` with a stable sort, so equal orders render in plugin-activation order. `tool:cordis` and `tool:workflow` both declared `order: 115`, while their activation order varies between clean platform compositions. ACP and SDK snapshot replays could therefore assemble the same sections in a different order from their committed `system-prompt.expected.md` files. + +## Decision + +Give the affected sequence distinct values without changing its established relative order: `tool:cordis` stays at 115, `tool:workflow` uses 115.5, `tool:ralph` stays at 116, continuable subagent guidance stays at 116.5, and child-report guidance stays at 117. Prompt text and tool schemas remain unchanged. + +## Alternatives considered + +**Normalize section order in the snapshot harness.** Rejected because the runtime, request header, and model prompt would remain sensitive to activation timing while only the fixture comparison hid the difference. + +**Tie-break equal orders by section name in the registry.** Rejected because it would silently reorder every existing tie. Explicit orders keep each model-visible placement local to the contributing plugin. + +## Consequences + +The Cordis and workflow guidance has a platform-independent order while Ralph remains before continuable subagent and child-report guidance. Prompt-section placements that require a stable relative position need distinct `order` values; other equal-order sections retain activation-order semantics and are outside this decision. + +## Testing + +The keyless ACP and SDK snapshot replays pin Cordis before workflow and preserve the workflow, Ralph, continuable-subagent, and child-report sequence. The full snapshot suite verifies the refreshed fixtures. diff --git a/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md new file mode 100644 index 0000000000..4a822b7925 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md @@ -0,0 +1,28 @@ +# Agent Note: 等序系统提示词分段按激活顺序渲染 + +Status: implemented +Archived: 2026-08-25 + +[English](2026-08-24-system-prompt-section-order-ties.md) | 中文 + +## Problem + +`SystemPromptRegistry` 使用稳定排序按 `order` 排列分段,因此相同 order 的分段会按插件激活顺序渲染。`tool:cordis` 与 `tool:workflow` 都声明了 `order: 115`,但两者在不同平台的全新组合中激活顺序不同。因此,ACP(Agent Client Protocol)与 SDK 的快照回放可能把相同分段组装成不同于已提交 `system-prompt.expected.md` 文件的顺序。 + +## Decision + +在不改变既有相对顺序的前提下,为受影响的分段序列指定互不相同的 order:`tool:cordis` 保持 115,`tool:workflow` 使用 115.5,`tool:ralph` 保持 116,可继续运行的子代理指引保持 116.5,子代理报告指引保持 117。提示词文本与工具 schema 保持不变。 + +## Alternatives considered + +**在快照 harness 中规范化分段顺序。** 已否决,因为运行时、请求标头和模型提示词仍然受激活时序影响,只有 fixture 比较会隐藏差异。 + +**在注册表中用分段名称打破并列。** 已否决,因为这会静默重排每一组现有并列。显式 order 让每个模型可见位置都由贡献该分段的插件就地决定。 + +## Consequences + +Cordis 与 workflow 指引具有不依赖平台的顺序,同时 Ralph 仍排在可继续运行的子代理指引和子代理报告指引之前。需要稳定相对位置的提示词分段必须使用互不相同的 `order`;其他等序分段仍采用激活顺序,不属于本决策的范围。 + +## Testing + +无密钥 ACP 与 SDK 快照回放会固定 Cordis 排在 workflow 之前,并保留 workflow、Ralph、可继续运行的子代理和子代理报告指引的顺序。完整快照套件验证刷新的 fixture。 diff --git a/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml new file mode 100644 index 0000000000..87a43d82ca --- /dev/null +++ b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.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-30-versioned-gui-welcome-onboarding.md: 370793dd4e6d744ea61fc9319a94728dbbf3e1fe +2026-07-30-versioned-gui-welcome-onboarding.zh.md: 7b99377d00127174d5bfd8d080107c2cb67e6fff diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.md similarity index 99% rename from .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md rename to .agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.md index 9c8684c051..370793dd4e 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.md +++ b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.md @@ -1,6 +1,7 @@ # Agent Note: Versioned GUI welcome onboarding Status: implemented +Archived: 2026-08-22 English | [中文](2026-07-30-versioned-gui-welcome-onboarding.zh.md) diff --git a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md similarity index 91% rename from .agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md rename to .agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md index 52c35bf46e..7b99377d00 100644 --- a/.agents/notes/implemented/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md +++ b/.agents/notes/archived/feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md @@ -1,6 +1,7 @@ # Agent Note: 版本化 GUI 欢迎引导 Status: implemented +Archived: 2026-08-22 [English](2026-07-30-versioned-gui-welcome-onboarding.md) | 中文 @@ -10,9 +11,9 @@ GUI 的凭据引导从 DeepSeek 专用的就绪状态检查开始,但内部测 ## 决策 -**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。`ui-settings-models` 现在以顺序 `-100` 注册恢复后的欢迎声明,以顺序 `0` 注册 DeepSeek 条件式凭据步骤;两者当前的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.md)持有。 +**设置外壳协调有序步骤。** `settings.onboarding` 仍是根作用域 list,但 `ui-settings` 会把其中各条目的 id 和顺序投影到一个协调器中,并且只挂载第一个未完成的步骤。当前注册方会收到 `complete()` 和 `openSection(id)`;所有权转移前,不会挂载后续步骤。`ui-settings-models` 现在以顺序 `-100` 注册恢复后的欢迎声明,以顺序 `0` 注册 DeepSeek 条件式凭据步骤;两者当前的共用展示由[共用弹窗引导决策](2026-08-13-shared-modal-product-onboarding.zh.md)持有。 -**产品欢迎步骤按版本管理并归功能插件所有。** 该声明曾由[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.md)历史决策移除,现在以新的测试阶段文案恢复在 `ui-settings-models` 中。`ui-settings-general` 仍不注册任何引导步骤;持有当前两个步骤的插件也持有文案、store 和共用弹窗。 +**产品欢迎步骤按版本管理并归功能插件所有。** 该声明曾由[移除首次启动内测声明](../simplification/2026-08-13-remove-first-run-beta-notice.zh.md)历史决策移除,现在以新的测试阶段文案恢复在 `ui-settings-models` 中。`ui-settings-general` 仍不注册任何引导步骤;持有当前两个步骤的插件也持有文案、store 和共用弹窗。 **持久化的 `ui-onboarding` 分节持有确认状态。** 宿主端在 user-settings seam 中注册它,存入当前 `$DSH_HOME/settings.yaml`;当前欢迎 store 通过既有公开 settings API 读写其中的 `welcomeNoticeVersion`。connection 插件通过 `ctx.connection.isLoopback` 统一发布当前页面是否使用 loopback authority;hostname 判定留在 connection 包内,其他客户端插件只消费服务状态,而不导入其实现。API Proxy 在可配置提供方 namespace 之外,通过封闭的允许列表暴露这一个产品 namespace,同时不会把它的变更视为模型目录失效事件。 diff --git a/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml new file mode 100644 index 0000000000..4cb64e37c0 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.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-08-06-bundled-dsh-badge-skill.md: 2909736d53f8aff41ca69e705de44657bc1b4f1e +2026-08-06-bundled-dsh-badge-skill.zh.md: de85e9476d3945cd13335ef596243bc2a126ae55 diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.md similarity index 98% rename from .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md rename to .agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.md index 4c6fbdcb76..2909736d53 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.md +++ b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.md @@ -1,6 +1,7 @@ # Agent Note: Bundled dsh badge skill Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-06-bundled-dsh-badge-skill.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.zh.md similarity index 83% rename from .agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md rename to .agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.zh.md index f249c31f6e..de85e9476d 100644 --- a/.agents/notes/implemented/feature/2026-08-06-bundled-dsh-badge-skill.zh.md +++ b/.agents/notes/archived/feature/2026-08-06-bundled-dsh-badge-skill.zh.md @@ -1,12 +1,13 @@ # Agent Note: 内置 dsh 徽章 skill Status: implemented +Archived: 2026-08-22 [English](2026-08-06-bundled-dsh-badge-skill.md) | 中文 ## 问题 -[Cordis 教程](../../../../docs/cordis-tutorial/index.md)的各个页面都使用官方「powered by dsh」徽章,但交付的 CLI(命令行界面)既没有用于在其他位置应用同样署名的可复用指令,也没有可显式选择加入的提供方。 +[Cordis 教程](../../../../docs/cordis-tutorial/index.zh.md)的各个页面都使用官方「powered by dsh」徽章,但交付的 CLI(命令行界面)既没有用于在其他位置应用同样署名的可复用指令,也没有可显式选择加入的提供方。 ## 决策 diff --git a/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml new file mode 100644 index 0000000000..7459c96dae --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-08-07-workspace-picker-composer-entry.md: 023cbe09dd75015a1555103642d1b66ce75aafc9 +2026-08-07-workspace-picker-composer-entry.zh.md: 6b84885b11fb5372a51d620b40ea7d24fe7e45a2 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md rename to .agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.md index dc9c26c291..023cbe09dd 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md +++ b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.md @@ -1,6 +1,7 @@ # Agent Note: The no-Workspace composer opens the existing picker Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-07-workspace-picker-composer-entry.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.zh.md similarity index 89% rename from .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md rename to .agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.zh.md index 585c59b123..6b84885b11 100644 --- a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md +++ b/.agents/notes/archived/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -1,12 +1,13 @@ # Agent Note: 未选择 Workspace 时从编辑器打开现有选择器 Status: implemented +Archived: 2026-08-22 [English](2026-08-07-workspace-picker-composer-entry.md) | 中文 ## 问题 -[Session scope 决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md)会在 Workspace 存在前保留同一个常驻编辑器,但 textarea 处于禁用状态,只有较小的 Workspace chip 能打开选择器。用户首次点击最显眼、也最熟悉的输入区域时,界面不会响应,尽管同一界面已有继续操作的入口。 +[Session scope 决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md)会在 Workspace 存在前保留同一个常驻编辑器,但 textarea 处于禁用状态,只有较小的 Workspace chip 能打开选择器。用户首次点击最显眼、也最熟悉的输入区域时,界面不会响应,尽管同一界面已有继续操作的入口。 ## 决策 diff --git a/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml new file mode 100644 index 0000000000..27eba69e62 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-08-10-creator-guidance-introduce-cue.md: 2954bb9dca6bd5359ab3ed4b7e32bd8336709a10 +2026-08-10-creator-guidance-introduce-cue.zh.md: 4f818b3a444cbc7bbd4355ac8e7a883aaa4bfa51 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md rename to .agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.md index 888fee7b3d..2954bb9dca 100644 --- a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md +++ b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.md @@ -1,6 +1,7 @@ # Agent Note: Creator guidance lands as an introduce cue on the preset chip Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md rename to .agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.zh.md index d80260abd1..4f818b3a44 100644 --- a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md +++ b/.agents/notes/archived/feature/2026-08-10-creator-guidance-introduce-cue.zh.md @@ -1,6 +1,7 @@ # Agent Note: 创造模式引导以介绍动效落在预设 chip 上 Status: implemented +Archived: 2026-08-22 [English](2026-08-10-creator-guidance-introduce-cue.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml new file mode 100644 index 0000000000..6fc55e3a3e --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.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-08-11-collapsible-ask-user-question-card.md: 08e87b0d1f05f47c5bc87ef9b32cab05ef229e5c +2026-08-11-collapsible-ask-user-question-card.zh.md: fd3b838ff174dcdb3d4994fe30b193d63f5c4d15 diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md rename to .agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.md index 5c7e62749e..08e87b0d1f 100644 --- a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.md +++ b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.md @@ -1,6 +1,7 @@ # Agent Note: Collapsible Ask-User Question Card Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-collapsible-ask-user-question-card.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md rename to .agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.zh.md index 5f4b5851e4..fd3b838ff1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-collapsible-ask-user-question-card.zh.md +++ b/.agents/notes/archived/feature/2026-08-11-collapsible-ask-user-question-card.zh.md @@ -1,6 +1,7 @@ # Agent Note: 可收起的提问卡片 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-collapsible-ask-user-question-card.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.i18n.yaml new file mode 100644 index 0000000000..da756e7493 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.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-08-11-web-export-command-and-dialog.md: aba9048f26237ea01e861a5ee6e31b89429629c8 +2026-08-11-web-export-command-and-dialog.zh.md: be4a3a53b1ff75cfbe176e258fb6a73e5abfee22 diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md rename to .agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.md index d28925500a..aba9048f26 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.md +++ b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.md @@ -1,6 +1,7 @@ # Agent Note: Web `/export` shares the streamed Session ZIP download Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-web-export-command-and-dialog.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md rename to .agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.zh.md index a9a9c4a5cc..be4a3a53b1 100644 --- a/.agents/notes/implemented/feature/2026-08-11-web-export-command-and-dialog.zh.md +++ b/.agents/notes/archived/feature/2026-08-11-web-export-command-and-dialog.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web `/export` 共用流式 Session ZIP 下载 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-web-export-command-and-dialog.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml b/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml new file mode 100644 index 0000000000..a4c2d4ce83 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.md +2026-08-18-product-subagent-failure-facts.md: b1d80cf66172ac67d38dbad873fa4cbd970a775c +2026-08-18-product-subagent-failure-facts.zh.md: df4b14b4a243f7768240678b8d434c7aef7d48a7 diff --git a/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.md b/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.md new file mode 100644 index 0000000000..b1d80cf661 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.md @@ -0,0 +1,81 @@ +# Agent Note: Product subagents expose bounded structured failure facts + +Status: implemented +Archived: 2026-08-21 + +English | [中文](2026-08-18-product-subagent-failure-facts.zh.md) + +## Problem + +The [Claude Code and Codex product providers](2026-08-04-claude-code-and-codex-subagent-backends.md) receive structured product failures, but a published run historically flattened most of them to the shared `error` stop reason. Product logs retained detail that the foreground parent and a [one-shot background Job](2026-08-12-product-subagent-one-shot-background-tasks.md) could not use to distinguish a product limit, an execution failure, or an early process exit. + +Copying SDK error text, app-server payloads, or stderr into the result would expose task text, paths, environment values, credentials, or product internals. Adding shared error fields would also make the provider-neutral [subagent seam](2026-06-21-subagent-capability-seam.md) own product version vocabularies that change independently. + +## Decision + +Each product Provider owns the mapping from its pinned official structured failures, current operation, and managed process outcome to one fixed safe diagnostic line. `SubagentResult` remains unchanged: consumers receive the existing bounded `diagnostic` string and do not parse its product-private fields. The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) supersedes this note's complete Claude Code subtype mirror; this note continues to own the current detailed Codex categories until that provider adopts the same simplification. + +### Safe diagnostic + +The structured line has this fixed order: + +```text +Product subagent failure (product: ; stage: ; category: ; HTTP status: ; exit code: ; signal: ) +``` + +The Provider omits unavailable optional fields. Exit code and signal are independent facts and are each retained when observed. A contributing permission decision from the [non-interactive permissions decision](2026-08-15-product-subagent-noninteractive-permissions.md) follows the structured line; the latest safe permission fact remains operation-local. The shared result boundary limits the complete text to 4096 UTF-8 bytes. + +Successful results and local cancellation expose no failure fact. Raw product errors, stderr, tool input, paths, environment values, credentials, and protocol payloads never enter the diagnostic. Startup and cleanup rejections use the same safe line in their Error message. Original failures remain on internal cause chains; Provider Host logs and forwarded stderr remain product-local observation only. + +### Claude Code facts + +The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) exclusively owns Claude Code categories, stages, process facts, permission ordering, and verification for Agent SDK 0.3.241 and Claude Code 2.1.241. This note carries no separate Claude category contract. + +### Codex facts + +Codex app-server 0.147.0 defines eleven string categories and five object variants. The Provider preserves `contextWindowExceeded`, `sessionBudgetExceeded`, `usageLimitExceeded`, `serverOverloaded`, `cyberPolicy`, `internalServerError`, `unauthorized`, `badRequest`, `threadRollbackFailed`, `sandboxError`, and `other`. It also preserves `httpConnectionFailed`, `responseStreamConnectionFailed`, `responseStreamDisconnected`, `responseTooManyFailedAttempts`, and `activeTurnNotSteerable`; the four connection/stream variants retain numeric `httpStatusCode`, while the active-turn variant does not expose `turnKind`. Unknown strings, objects with another variant set, malformed values, and unclassified exceptions use `unknown`. + +| Stage | Owned operation | Observable failure | +| --- | --- | --- | +| `initialize` | App-server spawn and initialize/initialized handshake | `start()` rejects with fixed safe facts and any process outcome already observed | +| `thread-start` | Ephemeral `thread/start` request and response validation | `start()` rejects with the thread stage and any available process outcome | +| `turn-start` | Published `turn/start` request, provisional ids, and early frames | The run resolves as `error` with a safe unknown fallback when no structured category exists | +| `turn` | Terminal notification, final-answer selection, and error-info mapping | The complete category and optional HTTP status reach the non-completed result | +| `process` | Managed app-server exits before another terminal path settles | The run resolves as `error` with `process-exit` and any available code and signal | +| `teardown` | Wire close and process-tree release | `dispose()` rejects independently; startup rollback aggregation exposes both startup and teardown lines | + +`contextWindowExceeded` remains `max-tokens`; every other known or unknown Codex category remains `error`, and `cyberPolicy` does not become `refusal`. + +### Ownership and lifecycle + +| Fact or resource | Owner | Consumer behavior | +| --- | --- | --- | +| Codex error category | Codex Provider over its pinned official app-server | The Provider preserves its current structured category and uses `unknown` outside the recognized set | +| Current failure stage | Product Provider operation | Derived at the failure site; never persisted or used as a recovery state | +| Exit code and signal | `dsh-subprocess` process handle | The Provider displays observed values without inferring missing ones | +| Diagnostic bytes and delivery | `dsh-subagent`, foreground tool, and Job runtime | The same bounded text is presented separately from assistant output in both scheduling modes | +| Raw product failure | Product runtime, internal cause chain, and Host observation | It remains internal and never becomes model-visible result text | + +## Verification + +Claude Code verification is owned by the [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md). Codex package tests pin all sixteen current error-info variants, HTTP status presence and absence, all six stages, unknown fallback, stop-reason preservation, permission ordering, sanitization, cancellation, concurrency, and cleanup aggregation. The real app-server fixture produces an actual Codex `internalServerError` and covers process/protocol failure and whole-tree quiescence. The keyless ACP snapshot records the Codex diagnostic in foreground error output, a background completion notice, and `job_output`. + +## Alternatives considered + +**Return raw SDK errors, app-server payloads, or stderr.** These values can contain commands, paths, workspace content, environment values, credentials, or upstream prose. A fixed allowlisted mapping preserves actionable facts without expanding the model-visible trust boundary. + +**Add a shared product-error enum or structured result fields.** Claude Code and Codex version their error unions independently. A shared enum would duplicate those authorities and force unrelated Providers and consumers to track product releases. + +**Parse generic stderr and exception messages.** Free-form text is neither stable nor safe. Only pinned structured product fields and the managed process outcome qualify as diagnostic input. + +**Persist stages or add a recovery controller.** The stage is derived from the current call site only when a failure is reported. Persistence, retries, resume, and remediation need separate ownership and user contracts. + +**Map product limits to new shared stop reasons.** Claude Code turn and budget limits are not token-window exhaustion, and an error category does not establish refusal semantics. Existing stop reasons remain unchanged. + +## Consequences + +The parent can distinguish the current Codex budget, usage, service, policy, request, connection, stream, rollback, sandbox, and active-turn categories without receiving raw product text. The [minimal-diagnostics decision](../simplification/2026-08-21-product-subagent-minimal-diagnostics.md) owns the corresponding Claude result. Foreground and background scheduling preserve the same fact because both consume one `SubagentResult`. + +The diagnostic is display text rather than a new public protocol. Callers may present it but must not branch on its punctuation or product-private category names. A pinned product-version upgrade revalidates the Provider mapping and evidence without requiring every official error member to remain model-visible. + +This decision adds no product session persistence, retry policy, recovery state, stderr classifier, authentication or configuration taxonomy, progress stream, or human interaction path. diff --git a/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.zh.md b/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.zh.md new file mode 100644 index 0000000000..df4b14b4a2 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-18-product-subagent-failure-facts.zh.md @@ -0,0 +1,81 @@ +# Agent Note: 产品 subagent 公开有界结构化失败事实 + +Status: implemented +Archived: 2026-08-21 + +[English](2026-08-18-product-subagent-failure-facts.md) | 中文 + +## Problem + +[Claude Code 与 Codex 产品提供方](2026-08-04-claude-code-and-codex-subagent-backends.zh.md)会收到结构化产品失败,但已发布运行以往会把其中大多数压成共享的 `error` 终止原因。产品日志保留了细节,前台父 agent 与[一次性后台 Job](2026-08-12-product-subagent-one-shot-background-tasks.zh.md)却无法据此区分产品限制、执行失败或进程提前退出。 + +若把 SDK 错误文本、app-server payload 或 stderr 复制进结果,就会暴露任务文本、路径、环境值、凭证或产品内部信息。若增加共享错误字段,又会让提供方无关的 [subagent seam](2026-06-21-subagent-capability-seam.zh.md)拥有彼此独立变化的产品版本词汇。 + +## Decision + +每个产品提供方分别拥有从锁定版本官方结构化失败、当前操作和受管进程结果到一行固定安全诊断的映射。`SubagentResult` 保持不变:消费方仍接收现有的有界 `diagnostic` 字符串,而且不解析其中由产品私有的字段。[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)已经取代本说明对 Claude Code 完整 subtype 的镜像;在 Codex 采用同一简化前,本说明继续负责其当前详细类别。 + +### 安全诊断 + +结构化行采用以下固定顺序: + +```text +Product subagent failure (product: ; stage: ; category: ; HTTP status: ; exit code: ; signal: ) +``` + +提供方会省略不可用的可选字段。退出码与信号是相互独立的事实,只要已观测到就分别保留。来自[非交互权限决策](2026-08-15-product-subagent-noninteractive-permissions.zh.md)且参与失败的权限决定会跟在结构化行之后;最新的安全权限事实仍只属于当前操作。共享结果边界会把完整文本限制在 4096 个 UTF-8 字节以内。 + +成功结果与本地取消都不公开失败事实。原始产品错误、stderr、工具输入、路径、环境值、凭证和协议 payload 绝不会进入诊断。启动与清理拒绝会在 Error 消息中使用同一安全行。原始失败保留在内部 cause 链中;提供方 Host 日志与转发的 stderr 也只作为产品本地观测。 + +### Claude Code 事实 + +[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)独占负责 Agent SDK 0.3.241 与 Claude Code 2.1.241 的 Claude Code 类别、阶段、进程事实、权限顺序与验证。本说明不再承载独立的 Claude 类别约定。 + +### Codex 事实 + +Codex app-server 0.147.0 定义十一种字符串类别与五种对象 variant。提供方会保留 `contextWindowExceeded`、`sessionBudgetExceeded`、`usageLimitExceeded`、`serverOverloaded`、`cyberPolicy`、`internalServerError`、`unauthorized`、`badRequest`、`threadRollbackFailed`、`sandboxError` 和 `other`。它还会保留 `httpConnectionFailed`、`responseStreamConnectionFailed`、`responseStreamDisconnected`、`responseTooManyFailedAttempts` 与 `activeTurnNotSteerable`;四种连接/stream variant 会保留数值 `httpStatusCode`,而 active-turn variant 不公开 `turnKind`。未知字符串、同时含其他 variant 的对象、格式错误值与未分类异常统一使用 `unknown`。 + +| 阶段 | 归属操作 | 可观察失败 | +| --- | --- | --- | +| `initialize` | App-server spawn 与 initialize/initialized 握手 | `start()` 以固定安全事实和已经观测到的进程结果拒绝 | +| `thread-start` | 临时 `thread/start` 请求与响应校验 | `start()` 以线程阶段和可用进程结果拒绝 | +| `turn-start` | 已发布 `turn/start` 请求、暂定 id 与早到 frame | 没有结构化类别时,运行以 `error` 和安全 unknown 回退兑现 | +| `turn` | 终态通知、最终答案选择与 error-info 映射 | 完整类别与可选 HTTP status 进入非完成结果 | +| `process` | 受管 app-server 在另一终态路径结算前退出 | 运行以 `error` 兑现,并携带 `process-exit` 以及可用的退出码与信号 | +| `teardown` | Wire 关闭与进程树释放 | `dispose()` 独立拒绝;启动回滚聚合会同时公开启动与 teardown 两行 | + +`contextWindowExceeded` 仍是 `max-tokens`;其他所有已知或未知 Codex 类别仍是 `error`,`cyberPolicy` 不会变成 `refusal`。 + +### 所有权与生命周期 + +| 事实或资源 | Owner | 消费方行为 | +| --- | --- | --- | +| Codex 错误类别 | Codex 提供方及其锁定的官方 app-server | 提供方保留当前结构化类别,并在已识别集合之外使用 `unknown` | +| 当前失败阶段 | 产品提供方操作 | 只在失败点派生;绝不持久化,也不作为恢复状态 | +| 退出码与信号 | `dsh-subprocess` 进程句柄 | 提供方展示已观测值,不推测缺失值 | +| 诊断字节与送达 | `dsh-subagent`、前台工具与 Job 运行时 | 两种调度模式都把同一份有界文本与 assistant 输出分开呈现 | +| 原始产品失败 | 产品运行时、内部 cause 链与 Host 观测 | 只保留在内部,绝不成为模型可见的结果文本 | + +## Verification + +Claude Code 验证由[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责。Codex 包测试固定当前全部十六种 error-info variant、HTTP status 存在与缺失、六个阶段、unknown 回退、终止原因保持不变、权限顺序、脱敏、取消、并发与清理聚合。真实 app-server fixture 会产生实际 Codex `internalServerError`,并覆盖进程/协议失败与整棵进程树完全停稳。无密钥 ACP snapshot 会在前台错误输出、后台完成通知和 `job_output` 中记录 Codex 诊断。 + +## Alternatives considered + +**返回原始 SDK 错误、app-server payload 或 stderr。** 这些值可能包含命令、路径、工作区内容、环境值、凭证或上游文本。固定白名单映射可以保留可操作事实,同时不扩大模型可见的信任边界。 + +**增加共享产品错误 enum 或结构化结果字段。** Claude Code 与 Codex 各自独立版本化错误联合。共享 enum 会复制这些权威,并迫使无关提供方和消费方跟随产品版本。 + +**解析通用 stderr 与异常消息。** 自由文本既不稳定也不安全。只有锁定版本产品提供的结构化字段和受管进程结果可以成为诊断输入。 + +**持久化阶段或增加恢复控制器。** 阶段只在报告失败时从当前调用点派生。持久化、重试、resume 与修复需要独立的所有权和用户约定。 + +**把产品限制映射为新的共享终止原因。** Claude Code 的轮次和预算限制并不表示 token 窗口耗尽,错误类别也不能证明拒绝语义。既有终止原因保持不变。 + +## Consequences + +父 agent 可以区分当前 Codex 的预算、用量、服务、策略、请求、连接、stream、回滚、sandbox 与 active-turn 类别,而不会收到原始产品文本。[最小诊断决策](../simplification/2026-08-21-product-subagent-minimal-diagnostics.zh.md)负责对应的 Claude 结果。前台与后台调度会保留同一事实,因为二者都消费同一个 `SubagentResult`。 + +诊断只是展示文本,不是新的公开协议。调用方可以呈现它,但不得根据其标点或产品私有类别名称进行分支。锁定产品版本升级时必须重新验证提供方映射与证据,但不要求每个官方错误成员都继续模型可见。 + +本决策不增加产品会话持久化、重试策略、恢复状态、stderr 分类器、身份验证或配置分类体系、进度流或人工交互路径。 diff --git a/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.i18n.yaml b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.i18n.yaml new file mode 100644 index 0000000000..d001e4a95c --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-08-18-web-home-path-tilde.md: 108674740c804a42ec3b0491505186215a9d9fcd +2026-08-18-web-home-path-tilde.zh.md: 1f742158f62b9b7fbb8eae8be35c13adc95f3a09 diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md rename to .agents/notes/archived/feature/2026-08-18-web-home-path-tilde.md index b148833bab..108674740c 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.md +++ b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.md @@ -1,6 +1,7 @@ # Agent Note: Web UI abbreviates POSIX home paths as `~` Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-18-web-home-path-tilde.zh.md) diff --git a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.zh.md similarity index 99% rename from .agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md rename to .agents/notes/archived/feature/2026-08-18-web-home-path-tilde.zh.md index 9d15cd6dca..1f742158f6 100644 --- a/.agents/notes/implemented/feature/2026-08-18-web-home-path-tilde.zh.md +++ b/.agents/notes/archived/feature/2026-08-18-web-home-path-tilde.zh.md @@ -1,6 +1,7 @@ # Agent Note: Web UI abbreviates POSIX home paths as `~` Status: implemented +Archived: 2026-08-22 [English](2026-08-18-web-home-path-tilde.md) | 中文 diff --git a/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml new file mode 100644 index 0000000000..fbbf621db9 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-08-19-high-cache-hit-decimal-display.md: 83c031bd1049ec26029d2e9ba0dc5cd623c3f867 +2026-08-19-high-cache-hit-decimal-display.zh.md: 62f27e39d37cf2445ac6796030092e892d82b581 diff --git a/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.md b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.md new file mode 100644 index 0000000000..83c031bd10 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.md @@ -0,0 +1,51 @@ +# Agent Note: High cache-hit decimal display + +Status: implemented +Archived: 2026-08-22 + +English | [中文](2026-08-19-high-cache-hit-decimal-display.zh.md) + +## Problem + +The Web conversation stats line rounded every non-empty cache-hit ratio to an integer. Once the actual ratio passed 99%, the display hid further progress, and a ratio of at least 99.5% appeared as 100% even while uncached input or cache writes remained. + +Users therefore could not distinguish a nearly complete cache hit from a true full hit. + +## Decision + +`StatsLine` continues to derive the ratio from the whole-session `tokenUsage` projection owned by `@deepseek-ai/dsh-token-meter`; the projection remains the only owner of the uncached-input, cache-read, cache-write, and output counts ([projection decision](../architecture/2026-07-29-projected-token-usage-and-request-context.md)). The presentation layer changes only the text inserted into the existing `stats.cacheHit` locale template. + +| Actual ratio | Display | +|---|---| +| No billed input | Cache-hit group omitted | +| Integer rounding is below 100% | Rounded integer | +| Non-full ratio whose current rounding is 100% | Minimum decimal precision whose rounded result is below 100% | +| 100% | `100%` | + +Every non-empty ratio starts at zero decimal places. A non-full ratio increases precision one place at a time only while rounding would produce 100%, so `99.1%` and `99.49%` remain `99%`, while `99.5%`, `99.95%`, and `99.995%` retain one, two, and three decimal places respectively. `StatsLine` uses exact small-factor comparisons over the safe-integer token counts, then scales the near-full gap only while the intermediate remains within that range. This avoids floating-point tie errors without imposing a precision cap or substitute label. A full hit does not carry a redundant decimal. The same derived string feeds the inline row and its overflow tooltip. + +## Ownership and lifecycle + +Token-meter continues to fold usage from the complete durable session log. `StatsLine` performs a synchronous display derivation whenever the standard projection value changes. It introduces no setting, stored percentage, event, wire field, client state, or recovery path. + +Live updates, reload replay, and reconnect recovery all restore the same `tokenUsage` counts and run the same display function. A missing projection still omits every token group, and a zero input denominator still omits only the cache-hit group. + +## Verification + +The component spec pins the zero denominator, ordinary integer rounding, half-step rounding at several decimal precisions, each precision boundary through three decimal places, a near-full cumulative sample that needs fourteen decimal places, the true `100%` result, both locales, and equality between inline and tooltip values. The assembled `lifecycle-chrome` replay sidecar selects `9,950 / 10,000 = 99.5%` as a deterministic ratio that integer rounding would misreport as 100% while the base session fixture remains recordable; the live assertion and post-reload browser snapshot both display `99.5%` without another model call. + +## Alternatives considered + +**Keep integer rounding for every ratio.** Rejected because it hides all movement above 99% and still reports some non-full hits as 100%. + +**Truncate the high band to one decimal.** Rejected because `99.95%`, `99.995%`, and still closer ratios all collapse to `99.9%` instead of retaining the minimum precision that distinguishes them from a full hit. + +**Cap precision and use a substitute such as `<100%`.** Rejected because the exact cumulative counts can produce the required numeric result, and a cap would make display behavior depend on an arbitrary presentation limit. + +**Show one decimal at every ratio.** Rejected because the additional low-band motion adds noise and changes the established display where integer precision is sufficient. + +**Persist a display percentage in token-meter.** Rejected because the projection already carries the exact counts, while presentation precision belongs to the Web stats line. A second stored value would duplicate derivable state and expand replay and wire responsibilities. + +## Consequences + +High cache-hit sessions remain visually stable until integer rounding would falsely report a full hit, then expose only the decimal places needed to preserve that distinction. Extremely close non-full ratios can therefore produce long decimal strings; this is the accepted cost of having no arbitrary precision cap or nonnumeric fallback. Every delivery and recovery path stays on the existing durable projection lifecycle. diff --git a/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.zh.md b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.zh.md new file mode 100644 index 0000000000..62f27e39d3 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-19-high-cache-hit-decimal-display.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 高缓存命中率的小数显示 + +Status: implemented +Archived: 2026-08-22 + +[English](2026-08-19-high-cache-hit-decimal-display.md) | 中文 + +## 问题 + +Web 会话统计行会把所有非空缓存命中率舍入为整数。真实比率超过 99% 后,显示会隐藏后续提升;比率达到 99.5% 时,即使仍有未缓存输入或缓存写入,也会显示为 100%。 + +用户因此无法区分接近完整的缓存命中与真实满命中。 + +## 决策 + +`StatsLine` 继续从 `@deepseek-ai/dsh-token-meter` 所拥有的完整会话 `tokenUsage` 投影派生比率;该投影仍是未缓存输入、缓存读取、缓存写入与输出计数的唯一所有方([投影决策](../architecture/2026-07-29-projected-token-usage-and-request-context.zh.md))。展示层只改变插入现有 `stats.cacheHit` locale 模板的文本。 + +| 真实比率 | 显示结果 | +|---|---| +| 没有计费输入 | 省略缓存命中分组 | +| 整数舍入结果低于 100% | 舍入后的整数 | +| 当前舍入结果为 100% 的非满命中 | 舍入结果低于 100% 所需的最少小数位 | +| 100% | `100%` | + +所有非空比率都从零位小数开始。非满命中只有在舍入结果会成为 100% 时才逐位增加精度,因此 `99.1%` 与 `99.49%` 仍显示为 `99%`,而 `99.5%`、`99.95%` 与 `99.995%` 分别保留一位、两位与三位小数。`StatsLine` 对安全整数 token 计数执行精确的小因子比较,并且只在中间值仍处于该范围内时缩放接近满命中的差值。该算法既避开浮点临界值误差,也不设置精度上限或替代文案。真实满命中不会携带多余的小数。同一份派生字符串同时用于行内统计与溢出 tooltip。 + +## 归属与生命周期 + +token-meter 继续从完整持久会话日志折叠用量。标准投影值变化时,`StatsLine` 同步派生显示文本。本决策不引入设置、持久百分比、事件、协议字段、客户端状态或恢复路径。 + +实时更新、刷新回放与重连恢复都会还原同一组 `tokenUsage` 计数,并运行同一个显示函数。投影缺失时仍会省略全部 token 分组;输入分母为零时仍只省略缓存命中分组。 + +## 验证 + +组件测试固定了零分母、普通整数舍入、多个小数精度上的半步舍入、直至三位小数的各个精度边界、需要十四位小数的近满累计样本、真实 `100%`、两种 locale,以及行内值与 tooltip 值的一致性。组装后的 `lifecycle-chrome` replay sidecar 将 `9,950 / 10,000 = 99.5%` 选作确定性测试输入;该比率按整数舍入会误报为 100%,同时基础会话 fixture 仍可重录。活跃页面断言与刷新后的浏览器快照都会显示 `99.5%`,且不会产生额外模型调用。 + +## 备选方案 + +**对所有比率继续使用整数舍入。** 不予采纳,因为它会隐藏 99% 以上的全部变化,并继续把部分非满命中显示为 100%。 + +**把高位区间向下截取到一位小数。** 不予采纳,因为 `99.95%`、`99.995%` 以及更接近满命中的比率都会坍缩为 `99.9%`,无法保留区分真实满命中所需的最少精度。 + +**限制精度并使用 `<100%` 等替代文案。** 不予采纳,因为精确累计计数能够产生所需的数值结果,而精度上限会让显示行为依赖任意的展示限制。 + +**所有比率都显示一位小数。** 不予采纳,因为低位区间的额外变化会增加无效抖动,并改变整数精度已经足够的既有显示。 + +**在 token-meter 中持久化显示百分比。** 不予采纳,因为投影已经携带精确计数,而展示精度属于 Web 统计行。第二个持久值会复制可派生状态,并扩大回放与协议职责。 + +## 后果 + +高缓存命中率会保持稳定的整数显示,直到整数舍入会错误地报告满命中;此时界面只展示维持区分所需的小数位。极接近满命中的非满比率可能因此产生较长的小数字符串,这是不设置任意精度上限或非数值回退所接受的代价。所有交付与恢复路径继续沿用既有持久投影生命周期。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 2c9391bc96..479a150d56 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -43,6 +43,9 @@ "architecture/2026-07-28-dsh-native-typescript-source-launch.i18n.yaml": "sha256:af071e07bce5d9bc8f3df65fed9dcd9b3779a98c5864badbd530363bda021b55", "architecture/2026-07-28-dsh-native-typescript-source-launch.md": "sha256:1b56e3454277ace713e2a01c4da538c756c45bf633fd24d7b16443d584afac5d", "architecture/2026-07-28-dsh-native-typescript-source-launch.zh.md": "sha256:8c0f97472c2c89d2c19ae5cfa68c6e67f32b50960b08b60b46496f78ea6ffad1", + "architecture/2026-08-11-plugin-settings-tabs.i18n.yaml": "sha256:0365da2b317fc5f94dd190064198565f4c624afc91d2e62161ab9170f79d11bc", + "architecture/2026-08-11-plugin-settings-tabs.md": "sha256:fdd92cfe55b6c4cd31b3f768dd46a2ecf129a04c9818249cbdd33857cf722bbf", + "architecture/2026-08-11-plugin-settings-tabs.zh.md": "sha256:8993df1a0178aba1ea35c460ee67c522900344a4b386287bba9dfac2bfb87efa", "bug-fix/2026-07-20-code-mode-result-card-completeness.i18n.yaml": "sha256:1035dae11d049d32ab09fd7d4f950eceae44bf46ba498b3cfaf3c75102b9fb64", "bug-fix/2026-07-20-code-mode-result-card-completeness.md": "sha256:6ca2c9d4df98be18813ef38b7462db880900b5bcd6944fbcd1b8f2258006b93e", "bug-fix/2026-07-20-code-mode-result-card-completeness.zh.md": "sha256:ed85fa7f935e5f525d566bc37a92014614983e649c75de9a9f244939097a7991", @@ -85,6 +88,9 @@ "bug-fix/2026-07-30-web-details-default-closed.i18n.yaml": "sha256:2af5559d727f3e4afdd4946eaf89ac212c81db611db78dbd9bfabb1c4661db17", "bug-fix/2026-07-30-web-details-default-closed.md": "sha256:27a280a817c8048718bb22927e7d9572cf99ffd0c044631e99e0fd6ea236876f", "bug-fix/2026-07-30-web-details-default-closed.zh.md": "sha256:e047c7d02cf4b95b0c7f78f4b79af254091294b05cc75e98a8bb860ae2074189", + "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.i18n.yaml": "sha256:36fc626dcbf1e276a36713e85860752cef0b36a5881f2493bdeb6d9654621b02", + "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.md": "sha256:3ece47f91ee5f7354ef73ca0562aafeec19f89a19d9a64c9e0a565fe6d8c2049", + "bug-fix/2026-07-31-composer-text-layers-share-one-scrollport.zh.md": "sha256:578e772ecbc1a4a39bddbb0a9f3fdbf67c70ff8c8952cc80d2caa3d8b76e9b36", "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.i18n.yaml": "sha256:42218a762ce0141d3cb43deb6c688d3705cdc4405e03851d486c78f3d25b70ef", "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.md": "sha256:a40992e89736131f5c487e5357848f14accd06e135dbec9ce242c968a5b11d43", "bug-fix/2026-07-31-hero-visible-while-blank-session-opens.zh.md": "sha256:e0cc576bc1c196affc9220ddabf15d735c347029c530c56454f0e585979101e1", @@ -94,12 +100,33 @@ "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", + "bug-fix/2026-08-04-large-history-pagination-call-stack.i18n.yaml": "sha256:9bb1ceec013521116ee73eb9ec28c5708ae9520d6e53dcd4a3621fd2283b215c", + "bug-fix/2026-08-04-large-history-pagination-call-stack.md": "sha256:38c5afd347b131abd6b73634d25210d593bcb1fd72c7ba501bad0b33fb639810", + "bug-fix/2026-08-04-large-history-pagination-call-stack.zh.md": "sha256:2a2790b3b3400c747e20b998edfa96788b4035ccfa5fd4100dbc9a0e694ed30e", + "bug-fix/2026-08-06-plan-narrow-viewport-regression.i18n.yaml": "sha256:fe0539da9ce4015c6deaf585350e586e99d86e0073a36e131b6f1f62cc13382b", + "bug-fix/2026-08-06-plan-narrow-viewport-regression.md": "sha256:ccecdf52213dd1f6ab9935db31906520b83a7d9166f612376877d612230d1331", + "bug-fix/2026-08-06-plan-narrow-viewport-regression.zh.md": "sha256:be10805f0cd5a4f0812c883ab7f3e1e9396b579be455696d5cd726434a26b3e1", "bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3", "bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6", "bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d", + "bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml": "sha256:d50452503b59aa22c81617888f9391f31f12a779c4b18efb2b4b6de1bd9702a6", + "bug-fix/2026-08-11-preset-card-description-clamp.md": "sha256:7eb8db697f3ad3dea8c0a6045331c05730a010404a89e2b08348cb7b0fad26c8", + "bug-fix/2026-08-11-preset-card-description-clamp.zh.md": "sha256:6d2f7b55ce02275a45adfdd853805e7daf2d6534929b77beb86685a54fc34f85", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.i18n.yaml": "sha256:3ce4f6e39e173fc304bf64deca9c95bcddc1dbb492e065ca8c267a7a40788588", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.md": "sha256:7b169aa4543edfc965de5a8b7b9e60aa9d9d5218693cd0b57908e2d482280723", "bug-fix/2026-08-12-collapsed-sidebar-shared-entry-motion.zh.md": "sha256:88db36c698800bf55c3c7531d6f92665576d978c29c15ff7d74215fb93376cb1", + "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.i18n.yaml": "sha256:23c26323f92a2172fd30fd724177b84d012cf4e18f1eff79ab092d4e0687ad4e", + "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.md": "sha256:f9edea8501df36d444d84790ef9b0ae5bed4283a9cc5a5403800b80908f3db39", + "bug-fix/2026-08-13-safari-textarea-soft-wrap-reflow.zh.md": "sha256:ddcf6bb67823d19dc98964fcb9d663a10bcf663573394cfd7b235d9801d6525a", + "bug-fix/2026-08-20-composer-edit-range-from-selection.i18n.yaml": "sha256:c91ed2d9cb2a9891011fcbbe46885bc1808e36831279d56bc5cea0b9b1515b55", + "bug-fix/2026-08-20-composer-edit-range-from-selection.md": "sha256:e36920dee0318a35eaf49bff8c574698902f3be51d6115d40a3f97fa1436bd47", + "bug-fix/2026-08-20-composer-edit-range-from-selection.zh.md": "sha256:41f44adc93797cf9073f19f954b6ac87147a2e6806f1ad051c80c3423f0175ae", + "bug-fix/2026-08-20-composer-reference-decoration-keys.i18n.yaml": "sha256:cadf1de336aa2756d1bc1c20c7679449390b0a7a4217fe9602996801b6bc1958", + "bug-fix/2026-08-20-composer-reference-decoration-keys.md": "sha256:0093eabd710f10ae1faca53be01c9404a9d63cf6a2cf4dbf226e458e6315e201", + "bug-fix/2026-08-20-composer-reference-decoration-keys.zh.md": "sha256:a5fb2a748cf6ff8353d536448a5469e731157ccc2d0bb43210ea5dc44dd8ed31", + "bug-fix/2026-08-24-system-prompt-section-order-ties.i18n.yaml": "sha256:f7a20bddd4544738ec0dbbfc52ea931f42317defa1674beb9a3c0daebd52fc2d", + "bug-fix/2026-08-24-system-prompt-section-order-ties.md": "sha256:108a97346eb7a62f1ab01f48dbb9fdd965e8991f53e382b0f501b916af0e9e23", + "bug-fix/2026-08-24-system-prompt-section-order-ties.zh.md": "sha256:3deaddfcf9736b3ff8d61b51093d7e46fdcc86103705033e4aa4c9d043794b16", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", @@ -241,6 +268,9 @@ "feature/2026-07-30-tui-details-command.i18n.yaml": "sha256:033cea6df0a16fc68cbdb435babdc6e75c1199a8e70e1a71d87c800c40f5a044", "feature/2026-07-30-tui-details-command.md": "sha256:a13478d4e55ec6d358209b51b541413ec75d0e20dfc22196ace28020f03f0c2d", "feature/2026-07-30-tui-details-command.zh.md": "sha256:de9c449b98468cef34ce4f9a9d2a854a5d8905eecd61f80e27a9a0e4495e9901", + "feature/2026-07-30-versioned-gui-welcome-onboarding.i18n.yaml": "sha256:3d453f1a8f1a642ed569d1900009b785e582614bcabf9fede566ecbd9842e3ef", + "feature/2026-07-30-versioned-gui-welcome-onboarding.md": "sha256:cfaa38cfec722ac3792a4770f7733372805a6c0571f4f08519f367976b0d2379", + "feature/2026-07-30-versioned-gui-welcome-onboarding.zh.md": "sha256:0ce0e4616580c583725aa3d28063dc009889d7f0a260a3cdda53ff48721c1ae4", "feature/2026-07-30-versioned-tui-first-run-welcome.i18n.yaml": "sha256:4c3fc380b0512ad7c00baacd0ac610e1a78ae45374311d9bd43bab6b5e29e630", "feature/2026-07-30-versioned-tui-first-run-welcome.md": "sha256:296f153e6c839f3743078e4f5aab3b2befc211c934835238668c57bdeae52231", "feature/2026-07-30-versioned-tui-first-run-welcome.zh.md": "sha256:82871a9cca1fec46bb08a5b39daad28a44bb2419dea367b4ae41af3cf07bfa65", @@ -259,9 +289,33 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-06-bundled-dsh-badge-skill.i18n.yaml": "sha256:4b568d89976a71b7b3864e13b36925bf055479213739ffd4ac81614e00e93e36", + "feature/2026-08-06-bundled-dsh-badge-skill.md": "sha256:7b67f7c09b7e2b2ca756983a8951dad3a15786b8cd3adc6e819f316c67d31b2c", + "feature/2026-08-06-bundled-dsh-badge-skill.zh.md": "sha256:dcc0acb2dca596196ac034a8e86a644131c5b7fb09b184ec9d8493f93019d2b1", + "feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml": "sha256:24a8bb2956371c7c840a662ac16dffbe04a6bb40a7296d01db86cb85da58d238", + "feature/2026-08-07-workspace-picker-composer-entry.md": "sha256:036212fbae6f5d7194e8c7fc9b1e7cd1c35251e9c227e895834a7d00bd5f69f8", + "feature/2026-08-07-workspace-picker-composer-entry.zh.md": "sha256:fb65c3330e8324f90d1270345e1ac941fc800e8caaf3b4bbee1bbb743f713262", "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", + "feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml": "sha256:74f519839f0cf82c7304bdeae41ae1cab8bb930bfb94f79ab44708acd3b72128", + "feature/2026-08-10-creator-guidance-introduce-cue.md": "sha256:3e25409dda498de150de18943ee332e1760963e377a40a365902b66f667fdc9f", + "feature/2026-08-10-creator-guidance-introduce-cue.zh.md": "sha256:203847010cab9e9d13c3969f17921d3a5aa0d69377eccfef555c7ff96572f162", + "feature/2026-08-11-collapsible-ask-user-question-card.i18n.yaml": "sha256:9c0873bbb1437bcd5025f5859e1dc447a6b936f19c2c2ad2250521a3aa773a12", + "feature/2026-08-11-collapsible-ask-user-question-card.md": "sha256:4f3b3f5d7020fefbbac8a3c36a97642721ef14a0476a7d8117bde8a128d25f42", + "feature/2026-08-11-collapsible-ask-user-question-card.zh.md": "sha256:e7186c92f77d337a875f981ae39b16331a1c5466a948415bcacfe108ad98fb63", + "feature/2026-08-11-web-export-command-and-dialog.i18n.yaml": "sha256:db7d523a2a1f82a86f532661bd2953ee8538d971d91f886e4bd4e0d88f7226b2", + "feature/2026-08-11-web-export-command-and-dialog.md": "sha256:ec44b47589ca7924018dc24f7fa73379a97b8f053d9e8ccce2aebb600230e47b", + "feature/2026-08-11-web-export-command-and-dialog.zh.md": "sha256:ad28e67d397c87300cfe1705ba3d206cc4d054e07f5647c095c718ac8cf4ec98", + "feature/2026-08-18-product-subagent-failure-facts.i18n.yaml": "sha256:0aa7a873fdd878ee7f4b0a850ecf16d7b652b4f85de979acb7efcdf90883b6c1", + "feature/2026-08-18-product-subagent-failure-facts.md": "sha256:f7e05703c44106359798e6e4b76e442a4107b62ff0363554382d4767e4806788", + "feature/2026-08-18-product-subagent-failure-facts.zh.md": "sha256:19d2619fb5b5c6e40305dd82432d837357afa433ab735504ec204a2c25582ce6", + "feature/2026-08-18-web-home-path-tilde.i18n.yaml": "sha256:f151e3e3514f59784fc646c2feb3075dc954c65110d48c2cc482ad486fc0b86f", + "feature/2026-08-18-web-home-path-tilde.md": "sha256:8c7ecf120ff8c81826160acab5fc906a2a0a14213bcd2958343cfea47328d68e", + "feature/2026-08-18-web-home-path-tilde.zh.md": "sha256:3486c5b42aed5bcadf12c62c5e1e6cf7c1b493fc1085ad7d154cdf2ec34076cc", + "feature/2026-08-19-high-cache-hit-decimal-display.i18n.yaml": "sha256:c2cb839ed676040ed62153c2aab65b677fa59739f69253af50246e79a2347620", + "feature/2026-08-19-high-cache-hit-decimal-display.md": "sha256:08cb68bfc379da47a05b816afac26126146d36d6248350d4380f7cb98607d573", + "feature/2026-08-19-high-cache-hit-decimal-display.zh.md": "sha256:9d7afe3e2fc3029fbccc643b432a01bcb3b5671750adb6945ac414ec843ca063", "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", @@ -322,6 +376,9 @@ "process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d", "process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857", "process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032", + "process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml": "sha256:dde0041399b253e3758045f0858488db8178ffc563ce889c8b396c87af6c3730", + "process/2026-08-12-documentation-site-navigation-and-chrome.md": "sha256:56cb836ed862378afd33eb5c1a9dc159958b35a0aed3bf4336fcf26ab0b84b8b", + "process/2026-08-12-documentation-site-navigation-and-chrome.zh.md": "sha256:f2dd4adde38a09fe312866a1e6dad0f465684d809287862f40f1a488acd4fe18", "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", @@ -406,6 +463,18 @@ "simplification/2026-08-03-explicit-config-dsh-entrypoint.i18n.yaml": "sha256:5466161f3fb8f2e8117fe8ff242675cc9fe9ef264d1e29b9bc586891c73c051a", "simplification/2026-08-03-explicit-config-dsh-entrypoint.md": "sha256:f23accae7d05c2e75cb73ec69b492307f1ce7526ecfa9f6b12a621e02fd1a0c3", "simplification/2026-08-03-explicit-config-dsh-entrypoint.zh.md": "sha256:a32d2c6ecf748a16a2c35b59cd2da2fda75769e3ab24be6a2e026d8655466db4", + "simplification/2026-08-11-cmdline-program-action.i18n.yaml": "sha256:e33b6dee66e23beabf03275e4e4f15134d23a82740ae7be6afd28c11c3163fca", + "simplification/2026-08-11-cmdline-program-action.md": "sha256:e6a274bd92a35c98ea24704161b408507876de3162c0f640b4bc70a86ab4d86f", + "simplification/2026-08-11-cmdline-program-action.zh.md": "sha256:bd213ad65ea6129c5360f28b2f52e6f3e224a58d07f56da190702939e7b402ee", + "simplification/2026-08-11-quickstart-documentation-home.i18n.yaml": "sha256:548c0ff16d40fed3b3318b0b9a26e11d53a60582ff457098a212fc64e7d67eac", + "simplification/2026-08-11-quickstart-documentation-home.md": "sha256:21946a828417aca4a214a874a35e88fe5a3e5989c330421f3849937b35bb9a9b", + "simplification/2026-08-11-quickstart-documentation-home.zh.md": "sha256:cb292a428d427cf36aff0a184347f0ab653331edb874daf3c5b58a0d1f8e6964", + "simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml": "sha256:51267b74e39544991bfe606e3f749a26914162e454cf357f26223a6ed8de5fad", + "simplification/2026-08-13-remove-first-run-beta-notice.md": "sha256:7ef5c712b8dff1152becee6a3f800acd5f7589d7b000f01544f57b175660bcc1", + "simplification/2026-08-13-remove-first-run-beta-notice.zh.md": "sha256:f899c79f838b97d1eea4a118de2cf00a97bae910d86d4aabdc447b4e02fb585f", + "simplification/2026-08-19-knip-config-cleanup.i18n.yaml": "sha256:ca8f5726aed57ce376c3fbd8b70113e235f3ba37dbf290683157fb4bf143ab13", + "simplification/2026-08-19-knip-config-cleanup.md": "sha256:18c61713b3358d3019dac096afc26ce8e5189002f626b25e858dfc5e6f626c8d", + "simplification/2026-08-19-knip-config-cleanup.zh.md": "sha256:b8ff16089c1a331603bb80278544c637cb16c1fa3bcfca3c5e1187152a1a0947", "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", @@ -429,6 +498,9 @@ "testing/2026-07-18-tui-terminal-state-snapshots.zh.md": "sha256:26750f240f6c8a7b28746f62fe161b357e9c5dd52867cc7037399f1ed6ff37fa", "testing/2026-07-26-execa-for-test-subprocess-plumbing.i18n.yaml": "sha256:dd45cddb591b892739b75b0c180bde7f14008f4769227b863571475be295e1e0", "testing/2026-07-26-execa-for-test-subprocess-plumbing.md": "sha256:1f45a69d0a7367ec5afbf112a77b355339b35270af8ff52696bee879cdf770d3", - "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b" + "testing/2026-07-26-execa-for-test-subprocess-plumbing.zh.md": "sha256:8a24bdc8376373d7a97f65cefc07078824bf918d6a9934056a025ecfafe8634b", + "testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml": "sha256:741e7e58e5e8a9c82d901c4a16a70cea9bd256eac0e94179b5a24a231bb9fe1f", + "testing/2026-08-12-required-python-runtime-pull-request-ci.md": "sha256:1f1273d7a550667533e29c76efd148aebf57581a91729c877b44a5e43a52d9ad", + "testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md": "sha256:6b9bf126c6b83d9b21e135d38df677c0d5623168b4353c6ddb706f76762c2193" } } diff --git a/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.i18n.yaml new file mode 100644 index 0000000000..439c0c1d47 --- /dev/null +++ b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.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-08-12-documentation-site-navigation-and-chrome.md: f33f017d54bcbb3583f37be27dcd6c69952bc66a +2026-08-12-documentation-site-navigation-and-chrome.zh.md: 7f3ff5c829c561167d8e2475cd1c2adf050975be diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md rename to .agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.md index 03cd44b94f..f33f017d54 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.md +++ b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.md @@ -1,6 +1,7 @@ # Agent Note: Documentation-site navigation and repository chrome Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-12-documentation-site-navigation-and-chrome.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md rename to .agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md index d0972f909e..7f3ff5c829 100644 --- a/.agents/notes/implemented/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md +++ b/.agents/notes/archived/process/2026-08-12-documentation-site-navigation-and-chrome.zh.md @@ -1,6 +1,7 @@ # Agent Note: 文档站导航与仓库 chrome Status: implemented +Archived: 2026-08-22 [English](2026-08-12-documentation-site-navigation-and-chrome.md) | 中文 diff --git a/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.i18n.yaml b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.i18n.yaml new file mode 100644 index 0000000000..69735dfe18 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.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-08-11-cmdline-program-action.md: 96cbe2342eef90e68dece3c78b12a2de1bbea7c0 +2026-08-11-cmdline-program-action.zh.md: f5a9fea1447c78c9f099d3b54acd5b90a21aa503 diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md rename to .agents/notes/archived/simplification/2026-08-11-cmdline-program-action.md index 40c4dae1d3..96cbe2342e 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.md +++ b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.md @@ -1,6 +1,7 @@ # Agent Note: parseCmdline runs the program's own commander action Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-cmdline-program-action.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.zh.md similarity index 85% rename from .agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md rename to .agents/notes/archived/simplification/2026-08-11-cmdline-program-action.zh.md index 91036f1c52..f5a9fea144 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-cmdline-program-action.zh.md +++ b/.agents/notes/archived/simplification/2026-08-11-cmdline-program-action.zh.md @@ -1,12 +1,13 @@ # Agent Note: parseCmdline 运行 program 自己的 commander action Status: implemented +Archived: 2026-08-22 [English](2026-08-11-cmdline-program-action.md) | 中文 ## Problem -`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan = (program, ctx) => T`,在解析成功后于该适配器的 catch 之内调用,使 plan 的 `program.error(...)` 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 `(() => ({}) as T)`,以及没有任何 plan 读取的 `ctx` 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 `parse` 内部运行,从中抛出的 `program.error(...)` 与语法拒绝一样遵循 `exitOverride`。 +`dsh-cmdline`([应用自有命令行](../architecture/2026-08-06-app-owned-command-line.zh.md))的 `parseCmdline` 曾带着一个自造的回调:`CmdlinePlan = (program, ctx) => T`,在解析成功后于该适配器的 catch 之内调用,使 plan 的 `program.error(...)` 与 help/解析错误共用同一条退出路径;它还带有只被测试使用、类型不健全的默认值 `(() => ({}) as T)`,以及没有任何 plan 读取的 `ctx` 参数。这整条接缝复制了 commander 本就定义的席位:命令的 action 处理器在 `parse` 内部运行,从中抛出的 `program.error(...)` 与语法拒绝一样遵循 `exitOverride`。 ## Decision diff --git a/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml new file mode 100644 index 0000000000..9ad4e8aacb --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write +2026-08-11-quickstart-documentation-home.md: 653c702eba4c90e52ee0539959d926ae23a98e6c +2026-08-11-quickstart-documentation-home.zh.md: 3d21566f9e4a822d095a115a5e65bfbaa3f947df diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md rename to .agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.md index 3fd98843fc..653c702eba 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.md +++ b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.md @@ -1,6 +1,7 @@ # Agent Note: Route documentation roots to quick start Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-11-quickstart-documentation-home.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.zh.md similarity index 88% rename from .agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md rename to .agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.zh.md index 2e3890f858..3d21566f9e 100644 --- a/.agents/notes/implemented/simplification/2026-08-11-quickstart-documentation-home.zh.md +++ b/.agents/notes/archived/simplification/2026-08-11-quickstart-documentation-home.zh.md @@ -1,6 +1,7 @@ # Agent Note: 将文档根路由指向快速开始 Status: implemented +Archived: 2026-08-22 [English](2026-08-11-quickstart-documentation-home.md) | 中文 @@ -12,7 +13,7 @@ Status: implemented 每个 locale 根路由都是重定向页面。`/` 将读者导向 `./guide/quickstart`,`/en/` 则把同一相对目标解析为 `/en/guide/quickstart`。当网站托管在源站的子路径下时,相对目标仍会保留配置的 `DOCS_BASE`。 -重定向由 `docs/user/index.md` 与 `docs/user/index.zh.md` 的 VitePress frontmatter 维护。对于 locale 首页,[文档网站投影器](../process/2026-07-13-documentation-site-projection.md)只发布这段 frontmatter,因此权威 Markdown 保留中英文语言切换行,且不会渲染第二个首页。投影器测试验证两个 locale 根路由都使用相对于各自 locale 的同一快速开始目标。 +重定向由 `docs/user/index.md` 与 `docs/user/index.zh.md` 的 VitePress frontmatter 维护。对于 locale 首页,[文档网站投影器](../process/2026-07-13-documentation-site-projection.zh.md)只发布这段 frontmatter,因此权威 Markdown 保留中英文语言切换行,且不会渲染第二个首页。投影器测试验证两个 locale 根路由都使用相对于各自 locale 的同一快速开始目标。 文档网站不承载产品定位和功能摘要。快速开始页面仍提供指南、开发、参考、搜索和 locale 导航。 diff --git a/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.i18n.yaml new file mode 100644 index 0000000000..abe87647e3 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.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-08-13-remove-first-run-beta-notice.md: 535d0a20a5805c137551e6047f40fc5cf53153b8 +2026-08-13-remove-first-run-beta-notice.zh.md: 20626bbd9d0bd13c00d4ee66f5dbc267c2e92082 diff --git a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.md similarity index 99% rename from .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md rename to .agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.md index 21396eb9cc..535d0a20a5 100644 --- a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.md +++ b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.md @@ -1,6 +1,7 @@ # Agent Note: Remove the first-run beta notice Status: implemented +Archived: 2026-08-22 English | [中文](2026-08-13-remove-first-run-beta-notice.zh.md) diff --git a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.zh.md similarity index 75% rename from .agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md rename to .agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.zh.md index 2818343804..20626bbd9d 100644 --- a/.agents/notes/implemented/simplification/2026-08-13-remove-first-run-beta-notice.zh.md +++ b/.agents/notes/archived/simplification/2026-08-13-remove-first-run-beta-notice.zh.md @@ -1,16 +1,17 @@ # Agent Note: 移除首次启动内测声明 Status: implemented +Archived: 2026-08-22 [English](2026-08-13-remove-first-run-beta-notice.md) | 中文 ## 问题 -GUI 每次首启都会先显示占满视口的内测声明:内部测试的定位表述,加上通过 `DSH_TELEMETRY_MODE` 开启 Session Log 上传的说明。会话遥测在 mode 未设置时已解析为 `DISABLED`([遥测默认关闭](../feature/2026-08-10-telemetry-default-off.md)),因此引导流程中关于遥测的全部内容就是一段教用户如何开启的提示,而内部测试的定位表述本身也不应出现在发布版本里。 +GUI 每次首启都会先显示占满视口的内测声明:内部测试的定位表述,加上通过 `DSH_TELEMETRY_MODE` 开启 Session Log 上传的说明。会话遥测在 mode 未设置时已解析为 `DISABLED`([遥测默认关闭](../feature/2026-08-10-telemetry-default-off.zh.md)),因此引导流程中关于遥测的全部内容就是一段教用户如何开启的提示,而内部测试的定位表述本身也不应出现在发布版本里。 ## 决策 -本决策当时把首启声明从组装后的产品中整体移除,而不是改写。`ui-settings-general` 不再注册任何 `settings.onboarding` 步骤;声明组件、确认 store、文案所有者文件和 locale 键均被删除,Host 则保留 `ui-onboarding` namespace,使既有设置文档继续有效。后续的[共用弹窗产品引导](../feature/2026-08-13-shared-modal-product-onboarding.md)在 `ui-settings-models` 中恢复了一份新的简洁测试阶段声明,复用该字段与后端契约,但不会恢复已移除的接管式布局或遥测说明。遥测的开启仍是显式的部署环境变量选择,记录在 [CLI reference README](../../../../apps/cli/reference/README.md) 中;恢复后的声明不涉及如何开启遥测。 +本决策当时把首启声明从组装后的产品中整体移除,而不是改写。`ui-settings-general` 不再注册任何 `settings.onboarding` 步骤;声明组件、确认 store、文案所有者文件和 locale 键均被删除,Host 则保留 `ui-onboarding` namespace,使既有设置文档继续有效。后续的[共用弹窗产品引导](../feature/2026-08-13-shared-modal-product-onboarding.zh.md)在 `ui-settings-models` 中恢复了一份新的简洁测试阶段声明,复用该字段与后端契约,但不会恢复已移除的接管式布局或遥测说明。遥测的开启仍是显式的部署环境变量选择,记录在 [CLI reference README](../../../../apps/cli/reference/README.zh.md) 中;恢复后的声明不涉及如何开启遥测。 ## 曾考虑的替代方案 diff --git a/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.i18n.yaml b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.i18n.yaml new file mode 100644 index 0000000000..6612299cc4 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.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-08-19-knip-config-cleanup.md: 56426aeb7ff53caf7828b3f252269559e596940d +2026-08-19-knip-config-cleanup.zh.md: a74fa831f2301d9b95d5e34b003585293501c874 diff --git a/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.md b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.md new file mode 100644 index 0000000000..56426aeb7f --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.md @@ -0,0 +1,32 @@ +# Agent Note: Deleted stale and duplicative knip.json workspace entries + +Status: implemented +Archived: 2026-08-22 + +English | [中文](2026-08-19-knip-config-cleanup.zh.md) + +## Problem + +`knip.json` carried workspace entries that did no work. Some pointed at packages that no longer exist, and some duplicated the `packages/*/*` glob default exactly. Both kinds made the file larger — 790 lines — and signaled a config that had outgrown the packages it described, so a reader could not tell which entries protected real behavior and which were inert. + +## Decision + +Deleted 15 `workspaces` entries: 2 stale keys naming packages absent from the working tree and from `HEAD`, and 13 entries whose `entry`/`project` values were byte-identical to the `packages/*/*` glob default. + +- Stale keys: `packages/util/home` (removed in `4a09d9b34d`, the harness-home resolver collapse) and `packages/client/web-ui` (no directory and no git history, an orphan key). knip 6.16 does not flag stale workspace keys — that stability check arrived in knip 6.18 — so these were inert config that only deleted when their packages disappeared. +- Glob-duplicate entries: `packages/host/webserver`, `packages/client/runtime`, `packages/core/tools`, `packages/context/tmux-context`, `packages/util/timeout`, `packages/util/output-retention`, `packages/goal/goal-round-driver`, `packages/goal/tool-goal`, `packages/util/home-paths`, `packages/fs/tool-fs-search`, `packages/client/ui-settings`, `packages/client/modules`, `packages/client/hmr`. Each declared exactly `entry: ["tests/**/*.spec.ts"]` and `project: ["src/**/*.ts", "tests/**/*.ts"]`, which equals the `packages/*/*` glob, and each package still exists, so the glob now covers it identically. + +The change is a deletion only: `knip.json` went from 790 to 655 lines with no behavioral change. `pnpm run knip` runs clean (zero issues, exit 0) before and after, because knip selects one workspace config per matched key (`getConfigKeyForWorkspace` uses specificity, not array merge), so a removed entry either lost an unresolvable target or fell back to an identical glob config. + +## Alternatives considered + +- Fold `zod` and other workspace-level `ignoreDependencies` up to the root. Rejected: the root `ignoreDependencies` is a repository-wide fallback, and these exemptions are deliberately workspace-scoped (the README of `cordis-host-runner` records why `src` cannot import the flagged dependency while the generated TypeRT face in `lib` needs it). Widening scope would mask a genuinely misplaced dependency in any future package. +- Upgrade knip to 6.18+ to get an automatic stale-workspace check. Deferred: 6.32.2 (latest at the time) re-flags many `@deepseek-ai/...` test dependencies as unused, i.e. it changes analysis semantics, not just adds hints. That is a separate dependency-upgrade decision with its own CI blast radius, not part of this cleanup. +- Keep the entries as documentation of intent. Rejected: an entry identical to the glob it sits under documents nothing beyond the glob itself, and a key naming an absent package actively misleads. + +## Consequences + +- `knip.json` is 135 lines shorter and names only packages that exist with config that differs from the glob default. +- Still-explicit entries (54) all carry a real reason to differ — an `e2e`/fixture/tsx `entry`, a `project` outside the default, or a workspace-scoped `ignoreDependencies`. +- knip 6.16 cannot itself detect the next stale key, so a package removal must still remember to drop its `knip.json` key; upgrading to 6.18+ (after the analysis-semantics change is separately assessed) restores that guard. +- This realizes the "never a restatement of the default stanza" criterion of the package-inventory proposal ([topic](../../proposed/process/2026-06-20-discover-package-inventory.md)); its remaining items — the e2e entry folding and the generated inventory — stay open there. diff --git a/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.zh.md b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.zh.md new file mode 100644 index 0000000000..a74fa831f2 --- /dev/null +++ b/.agents/notes/archived/simplification/2026-08-19-knip-config-cleanup.zh.md @@ -0,0 +1,32 @@ +# Agent Note: 删除 knip.json 中失效与重复的 workspace 条目 + +Status: implemented +Archived: 2026-08-22 + +[English](2026-08-19-knip-config-cleanup.md) | 中文 + +## 问题 + +`knip.json` 携带了大量不产生任何作用的 workspace 条目。其中一些指向已经不复存在的包,另一些与 `packages/*/*` 通配默认完全重复。这两类都让文件变大——790 行——并显现出配置已经超出了它所描述的包:读者无法分辨哪些条目在保护真实行为、哪些是惰性的。 + +## 决策 + +删除了 15 个 `workspaces` 条目:2 个指向工作树与 `HEAD` 中都不存在的包的失效键,以及 13 个 `entry`/`project` 与 `packages/*/*` 通配默认逐字节相同的条目。 + +- 失效键:`packages/util/home`(在 `4a09d9b34d`,harness home 解析器的合并改动中删除)和 `packages/client/web-ui`(无对应目录、无 git 历史,是孤儿键)。knip 6.16 不会标记失效的 workspace 键——这项稳定性检查在 knip 6.18 才引入——所以这些是本应在包消失时一并删除、却残留的惰性配置。 +- 通配重复条目:`packages/host/webserver`、`packages/client/runtime`、`packages/core/tools`、`packages/context/tmux-context`、`packages/util/timeout`、`packages/util/output-retention`、`packages/goal/goal-round-driver`、`packages/goal/tool-goal`、`packages/util/home-paths`、`packages/fs/tool-fs-search`、`packages/client/ui-settings`、`packages/client/modules`、`packages/client/hmr`。每个都恰好声明了 `entry: ["tests/**/*.spec.ts"]` 和 `project: ["src/**/*.ts", "tests/**/*.ts"]`,与 `packages/*/*` 通配相等,且这些包仍然存在,因此通配现在以完全相同的方式覆盖它们。 + +本改动只做删除:`knip.json` 从 790 行降到 655 行,行为不变。`pnpm run knip` 在改动前后都干净通过(零问题、退出码 0),因为 knip 为每个已匹配的键选取一条 workspace 配置(`getConfigKeyForWorkspace` 按特定优先、不做数组合并),所以被删条目要么丢掉了无法解析的目标,要么回退到一个完全相同的通配配置。 + +## 备选方案 + +- 把 `zod` 及其它 workspace 级 `ignoreDependencies` 上提到根级。否决:根级 `ignoreDependencies` 是全仓库兜底,而这些豁免是刻意限定在 workspace 的(`cordis-host-runner` 的 README 记录了为什么 `src` 无法 import 被标记的依赖、而生成的 `lib` 里的 TypeRT 契约面需要它)。扩大作用域会掩盖未来任何包里真正放错位置的依赖。 +- 升级 knip 到 6.18+ 以获得自动的失效 workspace 检查。延后:撰写时的最新版 6.32.2 会把大量 `@deepseek-ai/...` 测试依赖重新标记为未使用——也就是改变了分析语义,而不仅是新增提示。那是独立的依赖升级决定,带自己的 CI 影响面,不属于本次清理。 +- 保留这些条目作为意图的文档。否决:与它挂在下面的通配完全相同的条目,除了通配本身外不记录任何东西;而指向不存在包的键确实会误导人。 + +## 结果 + +- `knip.json` 缩短了 135 行,并且只列出确实存在、且配置与通配默认有差异的包。 +- 仍然显式的条目(54 个)都带有真实的特例理由——`e2e`/fixture/tsx 的 `entry`、超出默认的 `project`、或 workspace 级的 `ignoreDependencies`。 +- knip 6.16 自身无法检测下一个失效键,因此删除包时仍须记得清理它的 `knip.json` 键;升级到 6.18+(在分析语义的改动被单独评估之后)会恢复这道守卫。 +- 本改动落实了包清单提案中「绝不复述默认 stanza」的标准([议题](../../proposed/process/2026-06-20-discover-package-inventory.zh.md));其剩余项——e2e 入口折叠与生成的清单——仍在提案中保持开放。 diff --git a/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml new file mode 100644 index 0000000000..d6f71ad4c3 --- /dev/null +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md +2026-08-12-required-python-runtime-pull-request-ci.md: e7da767f22634bd50bc4fd38b1de34677c4124e7 +2026-08-12-required-python-runtime-pull-request-ci.zh.md: 702125b0da864eb35f1fe870748cc0e314b01a39 diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md similarity index 99% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md index 61b1e832be..e7da767f22 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.md +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.md @@ -1,6 +1,7 @@ # Agent Note: Required Python runtime pull-request validation Status: implemented +Archived: 2026-08-23 English | [中文](2026-08-12-required-python-runtime-pull-request-ci.zh.md) diff --git a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md similarity index 83% rename from .agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md rename to .agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md index 92bf80688d..702125b0da 100644 --- a/.agents/notes/implemented/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md +++ b/.agents/notes/archived/testing/2026-08-12-required-python-runtime-pull-request-ci.zh.md @@ -1,6 +1,7 @@ # Agent Note: 必需的 Python 运行时拉取请求验证 Status: implemented +Archived: 2026-08-23 [English](2026-08-12-required-python-runtime-pull-request-ci.md) | 中文 @@ -12,9 +13,9 @@ Status: implemented 每个拉取请求都在 [CI](../../../../.github/workflows/ci.yml) 中运行必需的 `python-runtime` 作业。该作业不使用路径过滤,调用共享的[单文件可执行程序构建器](../../../../.github/workflows/build-exe-for-python-sdk.yml)构建 `node24-linux-x64`,并参与 `all checks passed`。被调用的工作流会构建真实可执行文件,运行全部无密钥 Python 完整轮次和直接二进制场景(包括两份检入的快照),构建 SDK 与运行时 wheel 包,将二者安装进干净的虚拟环境,检查可执行文件与原生 addon 的 GLIBC 依赖,并在 manylinux 2.28 容器中运行已安装的 wheel 包。 -必需作业与 [Python 发布工作流](../process/2026-08-11-python-publication-workflow.md)共用同一构建器。其并发键包含调用方工作流,因此同一 ref 上的必需 CI 与显式完整发布验证不会互相取消。完整的 linux-x64、linux-arm64 和 macos-arm64 矩阵仍属于发布验证:平台无关的运行时、SDK 与快照行为只需要一个阻断合并的原生载体,而架构相关的可执行文件、addon、wheel 包标签与部署目标行为在发布前仍需要全部发布目标验证。 +必需作业与 [Python 发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)共用同一构建器。其并发键包含调用方工作流,因此同一 ref 上的必需 CI 与显式完整发布验证不会互相取消。完整的 linux-x64、linux-arm64 和 macos-arm64 矩阵仍属于发布验证:平台无关的运行时、SDK 与快照行为只需要一个阻断合并的原生载体,而架构相关的可执行文件、addon、wheel 包标签与部署目标行为在发布前仍需要全部发布目标验证。 -进阶 exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。极简场景的[模型可见快照](2026-08-13-python-minimal-model-visible-snapshot.md)覆盖了这份快照所占位化的已组装系统提示词、工具 schema 与消息列表。 +进阶 exe 快照会在比较前规范化不透明的会话、消息、subagent 和工作流运行标识符。因此,新增的持久化工作流事件会改变经过审阅的预期输出,但不会把随机运行标识符写入其中。极简场景的[模型可见快照](2026-08-13-python-minimal-model-visible-snapshot.zh.md)覆盖了这份快照所占位化的已组装系统提示词、工具 schema 与消息列表。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml index 0c0e629d26..98850aed4c 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md -2026-06-11-content-block-vocabulary.md: a31df6a7d16ea7cba649702fdb474dab34533c1b -2026-06-11-content-block-vocabulary.zh.md: 5ac882e9de7dea02cc6534aee99c46869cc9363f +2026-06-11-content-block-vocabulary.md: d7d3f6b43a3f65d1421f026e5b6c2cc1ba1eadd2 +2026-06-11-content-block-vocabulary.zh.md: ed4f915dff6dcb6dbc91400f9bfa5384253aea7b diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md index a31df6a7d1..d7d3f6b43a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.md @@ -25,4 +25,4 @@ In-session context injection (`context/message`) and mid-turn steering originall - Multimodal blocks return only with coordinated adapter, UI, and compaction support; see [the drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md). - Cache hints and assistant prefill remain absent until a shipping adapter can honor them; see the [producer-less variants](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md) and [inert request knobs](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Notes. - Every adapter pays a translation cost; the first real adapters have since validated the streaming protocol, and new adapters should continue proving their provider-specific mapping in adapter-local tests. -- IDs that cross package boundaries are branded (`CallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. +- IDs that cross package boundaries are branded (`ToolCallId`, the shared agent/session `SessionId`) — nominal typing at zero runtime cost. diff --git a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md index 5ac882e9de..ed4f915dff 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-content-block-vocabulary.zh.md @@ -12,7 +12,7 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 自主拥有词汇:消息是类型化内容块的数组(`text`、`reasoning`、`tool-call`、`tool-result`),其联合类型派生自可合并扩展的 `ContentBlockMap`,插件通过声明合并添加新的块类型。同一可合并扩展映射模式为所有「字符串化」字段提供类型(`MessageSource`、`FinishReason`、`TurnTrigger`、`TurnEndReason`)。流式输出采用原始分片协议;`BlockAssembler` 是唯一的共享组装实现。适配器负责转换为提供方的协议格式(wire format)——映射成本留在适配器中,正是它该在的地方。 -会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 +会话内上下文注入(`context/message`)和轮次中途 steering(中途引导)最初渲染为带标签的 user-role 信封(system-reminder 模式),而非引入新角色,因此适配器无需承担额外负担。如今两者都投影为无包装的普通用户内容;见[注入内容信封 Agent Note](../simplification/2026-07-20-unwrap-injected-content-envelopes.zh.md)。实际适配器验证已确认此渲染方式符合当前 DeepSeek 的行为;如果未来某提供方出现不兼容,应在该适配器内处理,而非引入新的规范角色。 ## 曾考虑的替代方案 @@ -25,4 +25,4 @@ harness 需要一套统一的内部消息语言,供 agent loop(智能体循 - 多模态块只有在适配器、UI 和上下文压缩(context compaction)三方协同支持后才会回归;见 [drop-image Agent Note](../../archived/simplification/2026-07-04-drop-image-content-block.md)。 - 缓存提示与 assistant prefill 在有实际适配器能兑现之前保持缺席;见[无生产者的词汇变体](../../archived/simplification/2026-07-04-prune-producerless-vocabulary-variants.md)与[无端到端可用路径的请求旋钮](../../archived/simplification/2026-07-04-drop-inert-request-knobs.md) Agent Note。 - 每个适配器都需承担翻译成本;首批真实适配器已验证了流式输出协议,新适配器应继续在适配器本地测试中验证其提供方特有的映射。 -- 跨包边界的 ID 使用品牌类型(`CallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 +- 跨包边界的 ID 使用品牌类型(`ToolCallId`、agent 与会话共享的 `SessionId`)——零运行时开销的名义类型。 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 e5c589dbf9..44272f7ca1 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-dev-invariants-over-deep-readonly.md 2026-06-11-dev-invariants-over-deep-readonly.md: 66980f1ee09c6112f72786d6c3a147aadbc57f6c -2026-06-11-dev-invariants-over-deep-readonly.zh.md: 0d694ea84e1b7c265491e9adb64757378b4185f1 +2026-06-11-dev-invariants-over-deep-readonly.zh.md: 576e53e0b27e65f6fa071ff649509223a7bc30ff 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 0d694ea84e..576e53e0b2 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 @@ -32,7 +32,7 @@ TypeScript readonly 类型不是充分的运行时边界。它们在程序运行 ### 包拥有的不变式配套插件检查关系 -`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.md))。 +`dsh-invariants` 注册可配置的 `ctx.invariants` 服务,本身不包含产品检查。每个包发布一个 `./invariant` 所有权配套插件;`dsh-session`、`dsh-agent`、`dsh-scope` 和 `dsh-agent-loop` 目前添加需要跟踪状态或观察另一个 seam 的规则:单调递增的序列号、轮次与步骤嵌套、工具调用/结果配对、合法的 agent(智能体)状态转换、主体正确的作用域分发,以及循环构建的请求与从其会话日志前缀重建的请求之间的相等性。全局启用和包名 regex 过滤器归该服务所有(见[包拥有的不变式服务](2026-07-19-package-owned-invariant-service.zh.md))。 当会话配套插件附加到已有会话或以种子记录初始化的会话时,它回放不可变日志以重建跟踪状态。服务为每项贡献提供一个可 dispose(资源释放)的子 fiber,因此轮次中途热重载是安全的,同时不赋予诊断逻辑对会话存储的所有权。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml index 760c3fd099..868d215253 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.md 2026-06-11-event-sourced-sessions.md: b6d17d2db1d9b489f3d4224683b61e62be485014 -2026-06-11-event-sourced-sessions.zh.md: a78349ea2385b3c9d32870757bb607f15996196d +2026-06-11-event-sourced-sessions.zh.md: 11e5740dc2636fc002ed7158167b1dfbb39a971d diff --git a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md index a78349ea23..11e5740dc2 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-event-sourced-sessions.zh.md @@ -24,5 +24,5 @@ MVP 要求严格的基于事件的追踪,以及完全可回放的会话(严 - 回放、追踪与遥测在结构上得到保证,而非事后附加。 - 持久化仍是插件关注点;内存存储随 dsh-session 一起提供。 -- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.md)在日志具备持久性后固定了其结构。 +- 事件词汇可通过合并扩展(插件可添加如压缩(compaction)事件);[会话持久化](2026-06-14-session-persistence.zh.md)在日志具备持久性后固定了其结构。 - 派生成本随日志长度增长,压缩(dsh-compaction)是预期的缓解手段,而不是改写日志。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml index 8e97d86ccf..57843018c6 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.md 2026-06-11-microkernel-event-taxonomy.md: 6cacbc30f29c863de0f8ddfa4df7251a64bc80ca -2026-06-11-microkernel-event-taxonomy.zh.md: 2e43b57d62d8fca692ffab379d1841e6a3e61fad +2026-06-11-microkernel-event-taxonomy.zh.md: 1c7919392b835c491b9dc606d46f638a708d2948 diff --git a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md index 2e43b57d62..1c7919392b 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-microkernel-event-taxonomy.zh.md @@ -25,7 +25,7 @@ Status: implemented ## 后果 -- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.md#the-feature--mechanism-map)是证明义务,保持更新)。 +- 每个 MVP 功能都映射到一个监听器([功能→机制映射](../../../../docs/cookbook/extension-cookbook.zh.md#the-feature--mechanism-map)是证明义务,保持更新)。 - HMR 与 dispose 无需额外工作:监听器和注册均为 Cordis effect。 - waterfall 语义(调用 `next()` 或短路)不直观,需要教学——在 AGENTS.md 中记录,并由组合测试覆盖。 - 循环必须具备防御性:插件异常在轮次级别被隔离,来自任何扩展点的 steering(中途引导)永远不会被搁置(有回归测试保障)。 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml index 2e6f1ad2df..821a220d9a 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.md 2026-06-11-runtime-arg-validation.md: 520823c76f147205805d306e97b5461f38b84c35 -2026-06-11-runtime-arg-validation.zh.md: 0a6ef40c93ec3550d6e991836950be849e86ede7 +2026-06-11-runtime-arg-validation.zh.md: bc2f743300be78cc2a01f027d5aca81fcb18b468 diff --git a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md index 0a6ef40c93..bc2f743300 100644 --- a/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-11-runtime-arg-validation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。 +`defineTool`([统一 schema DSL](2026-07-20-unified-json-value-schema-dsl.zh.md))为工具作者的 `execute(args)` 提供了经 `InferArgs` 映射的类型化参数。但该类型只是对运行时值的编译期声明,而这个值实际上是模型生成的 JSON:没有任何机制强制模型遵守 schema,因此畸形调用(缺少必需键、声明为数字的位置传入字符串,或字面量超出声明的集合)会以「仅在名义上类型化」的状态到达 `execute`。工具函数体随后要么在处理结构错误的数据时崩溃,要么在不报错的情况下行为异常。 ## 决策 @@ -17,8 +17,8 @@ Status: implemented ## 后果 - 模型会收到有关自身畸形调用的可操作反馈,而不是遭遇不透明的崩溃,弥合了 `InferArgs` 的承诺与运行时现实之间的鸿沟。 -- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。 -- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 +- 校验器与 `InferArgs` 必须保持一致;一项[属性测试](../testing/2026-06-11-property-based-testing.zh.md)生成满足 spec 的参数并断言它们通过 `validateArgs`(同时通过针对性改坏参数来断言其会被拒绝),通过自动化检查消除这种漂移风险。 +- `ToolArgsError` 是[结构化错误分类体系](2026-06-11-structured-error-taxonomy.zh.md)中 `HarnessError` 的子类,保留其 `code` 字段;读取 `.message` 的调用方不受该层级结构影响。 - 校验开销相对于一次模型调用可忽略不计。 diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml index 106164a71c..1c4eadabc1 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-capability-seams.md -2026-06-13-capability-seams.md: 2a166278ea454895177fa12b58f5493276f19cd1 -2026-06-13-capability-seams.zh.md: 0874af5826960ab9e718eb07b00c12b446edfd78 +2026-06-13-capability-seams.md: 46a2c39e927e859c7eb95956d8586f3bf04c7b1c +2026-06-13-capability-seams.zh.md: f44e3e68d2153149435b0fd0aaa5fd121cf3ecad diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md index 2a166278ea..46a2c39e92 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.md @@ -6,7 +6,7 @@ English | [中文](2026-06-13-capability-seams.zh.md) ## Problem -The harness has swappable capabilities — bash execution today, sandboxed/remote executors and alternative model providers tomorrow. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer API* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. +The harness has swappable capabilities, including shell execution and model providers. A capability has three concerns that change at different rates and for different reasons: the *contract* (what the capability is), the *implementation* (how it runs), and the *consumer API* (what the model and other plugins program against). Bundling them in one package couples those rates of change — swapping a local executor for a sandboxed one would churn the tool schemas the model sees, even though the model-facing contract never changed. This is distinct from "who provides vs. needs a capability at runtime", which Cordis already answers with services + `inject` (a provider registers `ctx.shell`; a consumer declares `inject: ['bash']` and its fiber pends until the service exists). That mechanism is necessary but doesn't dictate package boundaries; this Agent Note does. diff --git a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md index 0874af5826..f44e3e68d2 100644 --- a/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 具有可替换的能力:当前是 bash 执行,未来会有沙箱化/远程执行器和替代模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*约定*(这项能力是什么)、*实现*(它如何运行)、*消费方 API*(模型和其他插件面向什么编程)。将三者捆绑在一个包中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的约定从未改变。 +harness 具有可替换的能力,包括 shell 执行和模型提供方。一项能力涉及三个关注点,它们以不同速率、因不同原因变化:*约定*(这项能力是什么)、*实现*(它如何运行)、*消费方 API*(模型和其他插件面向什么编程)。将三者捆绑在一个包中会耦合这些变化速率——把本地执行器换成沙箱化执行器时,模型看到的工具 schema 也会被搅动,尽管面向模型的约定从未改变。 这与「谁在运行时提供、谁需要一项能力」是不同的问题,后者 Cordis 已通过服务 + `inject` 解决(提供方注册 `ctx.shell`;消费方声明 `inject: ['bash']`,其 fiber 挂起直到服务存在)。该机制是必要的,但不决定包的边界;本 Agent Note 决定的是包的边界。 @@ -26,7 +26,7 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh ## 术语:seam 指三者组合,而非接口 -一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.md#capability-seam)是规范条目。 +一个 **seam** 是完整的能力——三个角色合在一起:**Service Definition**(拥有 `ctx.` 和词汇的 Cordis `Service`)、一个或多个 **Service Provider**,以及一个或多个 **Consumer**。`packages/shell` 是规范范例——`dsh-shell` / `dsh-bash-local`+`dsh-bash-sandbox` / `dsh-tool-bash`。一个包可以承担多个角色,但单个角色本身不是 seam。「seam」一词严格保留给这种完整能力;命名其中一个组成部分时,应使用其角色、类、服务、约定或扩展点。[术语表](../../../../docs/glossary.zh.md#capability-seam)是规范条目。 ## 曾考虑的替代方案 @@ -35,4 +35,4 @@ Service Provider 与 Consumer 由此独立演进:沙箱化执行器替换 `dsh ## 后果 -分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。 +分离角色会增加包和样板代码(`package.json`、`tsconfig`、README 和注入接线)。换来的是:Service Provider 与 Consumer 独立发布和版本管理,新后端永远不会波及面向模型的约定。[AGENTS.md](../../../../AGENTS.md) 和 [architecture.md](../../../../docs/architecture.zh.md) 载有这项规则;bash 三件套是参考模板。本 Agent Note 记录为什么独立变化的角色通常需要拆分,而确实共享的关注点可以保持合并。 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 d44279eb42..3af4789414 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-13-twin-llm-adapters.md 2026-06-13-twin-llm-adapters.md: a4c87325a0b0d1ebe6cf8f95672e5de74ef37d57 -2026-06-13-twin-llm-adapters.zh.md: 2af7e7cca4d4c40e18cece1a4dd5c9cdb11901c8 +2026-06-13-twin-llm-adapters.zh.md: 36996750a16cc95393d727cffee3bcc53573eaf2 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 2af7e7cca4..36996750a1 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 @@ -6,7 +6,7 @@ Status: implemented ## 问题 -`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 +`dsh-llm` 拥有一套提供方无关的流式词汇:`StreamChunk` 协议(`block-start`、`text-delta`、`reasoning-delta`、`tool-call-delta`、`block-end`、`usage`、`finish`)以及内容块类型([内容块词汇](2026-06-11-content-block-vocabulary.zh.md))。如果词汇仅针对单个适配器定义,就有可能将该适配器的特异行为固化到「中立」约定中:唯一实现碰巧做了什么,什么就成为事实上的规范;在第二个提供方到来之前,抽象层未经验证——而届时修复这种泄漏的代价已经很高。 ## 决策 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml index 61f9754431..8f9aa62e49 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-14-session-persistence.md 2026-06-14-session-persistence.md: 62228bd2f5b25b13880a563818d08f3a2d52d956 -2026-06-14-session-persistence.zh.md: b10ceebd95d4d05ae3f7ea620183ad8fffd074ee +2026-06-14-session-persistence.zh.md: ebf004333c383336cd025aa8a4aabc9d1e07f0e5 diff --git a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md index b10ceebd95..ebf004333c 100644 --- a/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-14-session-persistence.zh.md @@ -8,28 +8,28 @@ Status: implemented 会话此前仅存在于内存中。示例插件 `session-jsonl.ts`(在两个示例中逐字节重复)是只写的遥测:它缓冲 `session/event` 并追加 JSON 行,没有读取/回放路径,没有崩溃安全性(无 fsync、无原子写入、dispose(资源释放)时采用 fire-and-forget 方式排空),没有列表功能,也没有格式版本控制。没有任何机制能将磁盘上的历史会话重新注入到活跃的 agent(智能体)中,因此持久恢复、持久 fork 以及宿主侧的会话浏览都无法实现。 -[事件溯源模型](2026-06-11-event-sourced-sessions.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 +[事件溯源模型](2026-06-11-event-sourced-sessions.zh.md)将仅追加日志作为唯一真源,并从中派生 LLM(大语言模型)历史。持久化必须忠实于这一设计:直接持久化现有的 `SessionEvent`,不引入需要来回转换的并行「持久化消息」类型。后端也必须可替换——当前用文件存储,以后用数据库存储——并由同一接口封装。 ## 决策 -持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.md),`dsh-shell` 模板),而非循环或核心逻辑: +持久化是一个具有抽象 Service Definition 的**能力 seam**([能力 seam](2026-06-13-capability-seams.zh.md),`dsh-shell` 模板),而非循环或核心逻辑: 1. **接口**(`dsh-session-persistence`,`ctx.sessionPersistence`):一个抽象的 `SessionPersistence` 服务,提供 `locate`/`create`/`append`/`prepare`/`load`/`inspect`/`readFrom`/`list`/`listSnapshots`。其持久化单元就是现有的 `SessionEvent`(`{ type, seq, time, data }`),原样复用,无转换类型。 -2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.md)是默认物理编码,也可通过配置使用原始行。 +2. **实现**(`dsh-session-persistence-jsonl`):每个会话一个仅追加的逻辑 JSONL 日志:先是一行 `SessionHeader`,随后是无损表示连续 `SessionEvent` 流的存储记录。符合条件的 `assistant/chunk` 增量连续段默认使用打包行;[带校验和的 Zstandard 帧](2026-07-19-zstandard-jsonl-session-logs.zh.md)是默认物理编码,也可通过配置使用原始行。 长期有效、存在争议的关键选择: - **规范的持久日志无损保留每个 `SessionEvent`,包括 `assistant/chunk`。** JSONL 存储可以将一段连续的增量事件编码为一条打包行,但逻辑读取方会重建精确的事件边界、序号与时间戳。`deriveMessages()` 跳过分片,而过滤分片的方案(Codex 的 `policy.rs`)很有吸引力,但 `seq = log.length` 以及 `events[i].seq === i` 验证要求*连续*的逻辑日志;过滤掉分片会留下空洞,同时破坏约定和恢复功能。基于分片过滤的投影可以作为派生视图在后续实现(带有自己的重新编号),但它不是规范日志。 -- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 +- **仅追加;崩溃的轮次被关闭,而非截断。** 已刷写的事件永不被重写。[语义检查点策略](../bug-fix/2026-07-21-semantic-session-checkpoints.zh.md)会在调用模型前排空请求、在调用工具前排空已记录的顶层调用,并在步骤结束后排空完整的响应/结果批次;循环则排空最终轮次边界。由于一个被中断的轮次可能包含大量有效工作,冷检查会保留其连续、可解析的事件,并在内存逻辑视图中为未应答的 assistant 调用添加按风险分类的错误结果、补一个缺失的 `step/end`,以及带 `{ kind: 'interrupted' }` 的 `turn/end`。`prepare` 或 `load` 在返回可恢复视图前提交这些收尾事件;合成结果保证恢复后的提供方 transcript(文本记录)仍然有效。只有不完整的最后一条记录会在提交修复时被丢弃;在最后一个真实 `turn/end` 处或之前出现解析错误或序号间隙,属于数据损坏,会使该会话不可加载。 - **文件后端为规范实现,数据库后端为经过验证的直接替换。** `SessionEvent` 1:1 映射到一行 `(session_id, seq, type, time, data)`:`append` 是 INSERT(在一个断言连续 seq 约定的事务中),读取使用 SELECT … ORDER BY seq。`dsh-session-persistence-sqlite` 正是如此:一个 `SessionPersistence` 子类,接口无变化(opencode 在 SQLite/WAL 上采用的正是这种接口形态),且通过与 JSONL 后端相同的 `runPersistenceContract` 测试套件。该约定以相同的语义约束两个后端(惰性物化、逻辑关闭中断轮次、修复只提交一次、连续 seq),一次表达在文件字节上,一次表达在数据库行上。其数据库拥有专用的 application id 与单调递增的 schema 版本。系统会在一个事务中为全新文件创建所有表并写入这两个 header 值;未版本化文件若带有任何用户定义的 schema 对象或应用标识、当前版本文件若带有外部应用标识,以及任何非当前版本文件,都会在修改日志模式之前被拒绝。 -- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.md)。) -- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 +- **元数据在日志之外。** 格式版本、cwd 和谱系是存储关注点,不是可回放的对话状态,因此它们存放在 `dsh-session` 拥有的 `SessionHeader` 中,并通过新的只读属性 `session.header` 附加到 `Session` 上——永远不进入 `SessionEventMap`,永远不到达 `deriveMessages()`。`createdAt` 是以 Unix epoch 毫秒表示的非负安全整数:运行时创建和持久化注册会拒绝小数值,JSONL 会验证解码后的 header,SQLite 则将其存入严格的 `INTEGER` 列。替代方案(一个可合并扩展的 `session/meta` 事件作为日志第 0 行)被否决:日志内事件会自然随 seed/fork 的会话携带,但元数据不是可回放状态,因此显式的日志外 header 边界是更清晰的取舍。(header 最初被拆分为不可变的 `SessionHeader` 加可变的 `SessionSummary`,二者的联合类型为 `SessionMeta`;可变 summary 后来因属于死状态而被移除——见 [移除可变会话摘要](../simplification/2026-06-19-drop-mutable-session-summary.zh.md)。) +- **`ctx.agents.create()` 和 `ctx.agents.resume()` 是异步工厂;恢复还跨越持久化边界。** `ctx.agents.resume({ resumeSessionId })` 通过 `ctx.sessionPersistence.prepare()` 取得精确的未发布 Session,以持久化 id 发布它,并继续其投影。[Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义历史检查与恢复之间的复用。agent loop(智能体循环)不会硬注入 `sessionPersistence`(那样会让非持久化的演示永远挂起);当它不存在时,`resume` 会以明确的错误拒绝。 ## 曾考虑的替代方案 上述每个关键选择都在陈述处记录了被否决的替代方案:**过滤分片的规范日志**(Codex 的 `policy.rs` 形式)破坏连续 seq 约定;**截断崩溃的轮次**会静默销毁长时间自主运行中的真实工作;**日志内 `session/meta` 事件作为第 0 行**——元数据不是可回放状态;**有限的非整数 `createdAt` 值**没有生产方,且与整数 Unix 毫秒存储及查询列不一致;**接受非全新的未版本化 SQLite 文件**可能覆盖无关对象或应用标识;**将 `sessionPersistence` 硬注入循环**会让非持久化的演示永远挂起。 -格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 +格式版本控制:header 携带一个 `version`;冷读取拒绝任何非当前版本。预发布阶段的会话格式仍固定为 `SESSION_FORMAT_VERSION = 0`,不承诺广泛兼容;当持久化用户数据确有需要时,协调器可以负责显式且范围受限的导入升级([消息标识机制引入前的消息恢复](../bug-fix/2026-07-28-load-pre-identity-session-messages.zh.md))。仅追加 + 刷写对尾部的不完整写入具有健壮性(冷准备时可容忍),但无法抵御未使用 fsync 时在行写入中途断电;数据库/WAL 后端是该场景下更强的选项。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml index 9898684b9e..c3984a9fa9 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md 2026-06-17-filesystem-capability-seam.md: 6265aebf5e7ffdd4ec4dc0083aa55adb34ee78a1 -2026-06-17-filesystem-capability-seam.zh.md: 024421ed59fa1e6c29bcc12ab51f69ac19a0c231 +2026-06-17-filesystem-capability-seam.zh.md: f0f1cb132fe06ebf669f0310f0066f4fdd36ad0b diff --git a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md index 024421ed59..f0f1cb132f 100644 --- a/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.zh.md @@ -20,7 +20,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local ## 决策 -文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): +文件系统访问是一个一等的能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-fs`(`packages/fs/fs`)拥有抽象的 `ctx.fs` 服务、文件系统词汇类型,以及 `fs/*` 策略事件词汇。 2. `@deepseek-ai/dsh-fs-local`(`packages/fs/fs-local`)提供第一个实现,以本地文件系统为后端。 @@ -28,7 +28,7 @@ harness 已有一个具体的 `bash` 能力 seam(`dsh-shell` / `dsh-bash-local Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需要不同后端的部署只需为 `ctx.fs` 加载不同的提供方,无需改动工具 schema 或面向模型的提示词引导。 -读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 做出。 +读后写/编辑与观测状态策略是第四个包 `@deepseek-ai/dsh-fs-observation-policy`(`packages/fs/fs-observation-policy`),通过 `fs/*` 事件门控贡献,而非挂在 `ctx.fs` 上;加载 `dsh-tool-fs` 的部署同时加载 `dsh-fs-observation-policy` 以获得读后写/编辑能力。本决策确立了由三个包构成的边界;策略从提供方基类拆出的决策由 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 做出,其以事件门控插件(而非方法服务)实现的方式由 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 做出。 第一个后端有意仅限本地:`dsh-fs-local` 基于宿主文件系统实现 `ctx.fs`。未来的兄弟后端可在同一接口之后提供沙箱、远程、虚拟或项目作用域的文件系统。 @@ -36,7 +36,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 文件系统权限和沙箱并非此拆分所隐含。本地后端从其配置的基目录解析相对路径,但路径包含约束策略是独立的决策:要么由更严格的 `ctx.fs` 实现强制执行,要么由权限/沙箱插件包装 `tools/execute` 并在调用到达消费方之前否决。 -读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.md) Agent Note。 +读后写/编辑与观测状态属于 `dsh-fs-observation-policy`,而非 `ctx.fs`。通过 `fs/*` 事件门控,策略按不透明 actor 记录版本,并提供可选的变更期望;提供方原子性地强制新鲜度。`dsh-tool-fs` 发出事件但不依赖策略。见[拆分文件系统 seam](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)和[事件门控插件](2026-06-26-file-context-as-event-gate.zh.md) Agent Note。 ## 包拓扑 @@ -84,7 +84,7 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - 不透明的 `targetKey`,用于陈旧守护和文件状态查找。本地后端可能使用类似 realpath 的键;远程后端可能使用工作区 URI 或文件 id。消费方禁止解析或假设它是本地绝对路径。 - `displayPath`,用于面向模型/UI 的输出。根据后端不同,它可能是本地绝对路径、工作区相对路径或远程 URI。 -即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)说明这些事实为何属于文件系统 seam。 +即使另一项能力共享提供方的执行环境,`targetKey` 仍保持不透明。这类消费方通过提供方的 `processPath(target)`、`fileUrl(target)` 或 `contains(parent, child)` 获取所需事实;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)说明这些事实为何属于文件系统 seam。 读取和变更结果必须包含不透明的文件 `version`。本地后端从 bigint stat 元数据(`dev`、`ino`、`size`、`mtimeNs` 和 `ctimeNs`)派生令牌,因此同大小重写和 inode 替换都会可靠地使消费方失效;远程后端可以使用 revision id 或类似 hash 的令牌。`dsh-fs-observation-policy` 插件记录版本用于陈旧检查;消费方可以展示相关元数据但禁止解释版本令牌。 @@ -140,7 +140,9 @@ Consumer 包仅依赖 Service Definition 包,从不依赖 `dsh-fs-local`。需 - **面向模型的工具直接基于 `node:fs`**:工具包将同时承担执行策略、路径解析、原子写入、文本解码和编辑语义,耦合问题部分所列的三个独立变化的关注点,且任何后端替换都会搅动 schema。 - **单一合并包 `dsh-fs-tools`**:seam 之前的形态;以与 bash 相同的 Service Definition / Service Provider / Consumer 拆分理由否决,且合并名称从未成为公开 API。 -- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 +- **观测状态放在 `ctx.fs` 上**:本 Agent Note 最初落地的形态;被 [拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 和 [事件门控 Agent Note](2026-06-26-file-context-as-event-gate.zh.md) 取代:沙箱/远程后端不应继承面向模型的观测策略,因此提供方只保留版本令牌和可选的版本守护变更。 + + ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml index 0ecda2484f..03a0440ca1 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.md 2026-06-18-agent-lifecycle-and-ownership-contracts.md: 9bc558bfce75892b0ebdb80e9f8735d440cabaf4 -2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: 65e0018c3f2ba56043898050c8e0b49929f8e7e7 +2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md: bbd0a7219f902e5ead4d190c58e9a5d4da2131fd diff --git a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md index 65e0018c3f..bbd0a7219f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-agent-lifecycle-and-ownership-contracts.zh.md @@ -14,7 +14,7 @@ ACP(Agent Client Protocol)与 tool-bash 的若干限制是同一个所有权 ### 1. 队列感知的 `Agent.cancel(cause?)` -`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.md)规定了当前的 cause、signal 生命周期与协作式结算约定。 +`Agent` 接口新增 `cancel()` 动词——唯一的公开停止原语。(它最初与范围更窄、仅作用于步骤的 `abort()` 一同交付;后者后来因无人使用而移除,使 `cancel()` 成为唯一公开的停止工作方式。)它清空 inbox 的 queued + steering FIFO,在存在活跃轮次时中止它,并保留一个不带 cause 的 pre-run 标记,使在被领取前被取消的提示词永不运行,而后来的提示词仍保持独立。有效调用会在清空或中止前发出 `agent/cancel-requested`,携带类型化的 `user | parent` cause;空闲取消不发出任何事件,也不会使下一条提示词搁浅。`whenIdle()` 会在取消后达到完全停稳,ACP 的 `session/cancel` 映射到 `user`。[显式轮次取消决策](2026-07-16-explicit-turn-cancellation.zh.md)规定了当前的 cause、signal 生命周期与协作式结算约定。 ### 2. `AgentHandle` 异步释放器 @@ -43,7 +43,7 @@ bash 所有者 token 比较依赖共享的 `Agent.id`/`SessionId` 在存活 agen - **公开的 `BashTask.owner` 字段**而非 `ShellExecutor.ownerOf(id)` Service Definition 方法:否决。一条读取路径即可,无需冗余 API。 - **为 agent 的会话生命周期使用兄弟 Cordis effect**:否决。fiber 卸载时并发释放兄弟 effect(`Promise.all`),store 拥有的 append 发布钩子的移除与循环的关闭 `session/flush` 产生竞争;单一复合 effect 的有序 LIFO 链才能在两条释放路径上都捕获关闭的 `turn/end`。 -- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.md))。 +- **在 `cancel()` 之外另设一个仅中止步骤的 `abort()`**:最初发布过,后因无人使用而移除;`cancel()` 是唯一的公开停止原语(见[公开停止接口 Agent Note](../simplification/2026-06-20-public-agent-stop-api.zh.md))。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml index 38a665a0fd..5148c8a648 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md -2026-06-18-shared-persistence-write-coordinator.md: 93b6cd1bd058499e71948d3909de8e4c076b445e -2026-06-18-shared-persistence-write-coordinator.zh.md: 9e1bc736d4dba5e59b763710976425fa0ae76c30 +2026-06-18-shared-persistence-write-coordinator.md: 8392ec726ff44e8a7173f48ef7d5cc4826b7e882 +2026-06-18-shared-persistence-write-coordinator.zh.md: e160f29247ae5cd02aaa8388c141faec64001857 diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md index 93b6cd1bd0..8392ec726f 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.md @@ -16,15 +16,20 @@ Composition, not inheritance. The coordinator is a concrete class the backend ho The coordinator holds one lifecycle entry for each exact live `Session`: initialization plus a package-private write controller that owns pending events, a fixed batching deadline, the active write, failure retention, and the shared flush barrier. Each `session/event` enters that bounded write path, and `session/flush` bypasses the wait to observe quiescence. The [flush-controller simplification](../simplification/2026-07-23-collapse-persistence-flush-state.md) owns controller consolidation; the [bounded batching decision](2026-08-08-bounded-session-persistence-write-batching.md) owns scheduling cadence. +Creation borrows the exact `Session.events` snapshot as its persistence seed. `Session` has already detached, validated, and deeply frozen every event, and the snapshot array remains stable when later appends replace the cached view. The coordinator and its backend hooks only read this typed in-process value, so cloning the complete log again would duplicate the ownership work described by the [agent-scope runtime decision](2026-07-12-agent-scope-runtime-design.md#session-append-materialize-validate-commit-notify). Public persistence `append()` still snapshots caller-owned input at its API boundary. + +Prepared-session suffixes and events admitted to the write-behind queue retain their existing copies. Those paths establish asynchronous queue ownership one suffix or event at a time and have no measured whole-log clone cost; removing their copies remains a separate ownership audit rather than part of creation-seed borrowing. + The coordinator retires a session from `session/disposed`: it waits for the controller's initialization and current flush, serializes a final drain, and removes the controller and owned per-id state only after success. A failure leaves the controller discoverable for backend teardown to retry. Settled per-id chain tails remove themselves only when they are still current, so a completion cannot erase a newer operation for the same id. Backend teardown unregisters write-path listeners, flushes every remaining controller, awaits per-id operations, and then closes the backend. ### The hook interface (`PersistenceBackend`) -Five required members plus an optional lifecycle hook form the only boundary between the coordinator and storage: +Five required members plus optional empty-materialization and lifecycle hooks form the only boundary between the coordinator and storage: - `name` — backend label for the dispose-failure `AggregateError`. - `loadStored(id)` — read one stored prefix by id across every storage scope (every JSONL project directory; SQLite's id is globally unique). Preparation, logical load/inspection, physical suffix reads, live adoption, and the create-collision probe share this lookup. The coordinator asserts the returned id and rejects a stored/live cwd mismatch before repair or state publication. -- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized (the materialize-write and the first event batch must commit together — a crash between them must not leave a materialized-but-empty session; this is why there is no separate `materialize` hook). +- `appendBatch(meta, events, isMaterialized)` — durably append a contiguous batch, lazily materializing the session ATOMICALLY when not yet materialized. Ordinary creation therefore cannot leave an abandoned materialized-but-empty session. +- `materializeHeader?(meta)` — explicitly persist a header-only session for `SessionPersistence.ensureMaterialized(session)`. This is reserved for a lifecycle frontend that treats an empty session itself as a resumable durable resource; [standard ACP automation controls](../feature/2026-08-22-standard-acp-automation-controls.md) are the first consumer. Backends that support that lifecycle implement the hook; lazy creation remains the default. - `commitRepair(meta, tornMarker, closers)` — make a crash repair durable: truncate the torn tail (iff `tornMarker !== undefined`) and append `closers`. **NOT required to be atomic** — JSONL legitimately truncates-then-appends in two fsync'd steps, SQLite does DELETE+INSERT in one transaction. Used by `prepare`/`load` (truncate + synthetic closers) and live-adoption (truncate only, `closers = []`). - `list()` — list all stored metadata. - `close?()` — optional lifecycle teardown (SQLite closes its db handle; JSONL omits it), awaited in the dispose effect AFTER the quiescence drain so a close failure never masks a drain error. diff --git a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md index 9e1bc736d4..e160f29247 100644 --- a/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-18-shared-persistence-write-coordinator.zh.md @@ -14,17 +14,22 @@ Status: implemented 组合,而非继承。协调器是后端持有的具体类,不是后端继承的基类。协调器让非常规后端与继承层级作斗争的风险由此规避:后端只暴露钩子,无法触及协调器的私有编排状态。第三方后端仍然可以完全不使用协调器、直接实现抽象服务,包括不可变逻辑检查,以及通过 `load` 实现的默认准备回退。 -协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.md)定义。 +协调器为每个存活的 `Session` 实例持有一个生命周期条目:初始化,加上一个包私有写入控制器,后者负责待处理事件、固定批处理截止时间、活跃写入、失败保留和共享 flush 屏障。每个 `session/event` 都进入这条有界写入路径,`session/flush` 则绕过等待以观察完全停稳。控制器归并由 [flush 控制器简化](../simplification/2026-07-23-collapse-persistence-flush-state.zh.md)定义;调度节奏由[有界批处理决策](2026-08-08-bounded-session-persistence-write-batching.zh.md)定义。 + +创建流程将 `Session.events` 的原始快照借作持久化种子。`Session` 已经分离、验证并深度冻结每个事件,后续追加会替换缓存视图,因此该快照数组保持稳定。协调器及其后端钩子只读取这个有类型的进程内值;再次克隆完整日志会重复 [agent scope 运行时决策](2026-07-12-agent-scope-runtime-design.zh.md#session-append-materialize-validate-commit-notify)规定的所有权工作。持久化服务的公开 `append()` 仍在 API 边界为调用方拥有的输入创建快照。 + +已准备 Session 的后缀,以及进入 write-behind 队列的事件,仍保留现有复制。这些路径会逐个后缀或事件建立异步队列所有权,且没有已测得的完整日志克隆成本;移除这些复制属于单独的所有权审计,不属于创建种子的借用决策。 协调器通过 `session/disposed` 退役会话:它等待控制器完成初始化和当前 flush,串行执行最后一次排空,且仅在成功后才移除控制器与其拥有的每 id 状态。失败时保持控制器可被找到,以供后端 teardown(拆除)重试。每个 id 的已结算链尾仅在其仍是当前链尾时才移除自身,因此旧操作完成后不会抹除同一 id 的新操作。后端 teardown 会注销写入路径监听器、flush 每个剩余的控制器、等待所有按 id 串行化的操作,最后关闭后端。 ### 钩子接口(`PersistenceBackend`) -五个必需成员加一个可选的生命周期钩子,构成协调器与存储之间唯一的边界: +五个必需成员加可选的空会话实体化与生命周期钩子,构成协调器与存储之间唯一的边界: - `name`——后端标签,用于 dispose 失败时的 `AggregateError`。 - `loadStored(id)`——按 id 跨所有存储范围读取一个已存储前缀(JSONL 的所有项目目录;SQLite 的 id 全局唯一)。准备、逻辑加载/检查、物理后缀读取、存活会话接管与创建碰撞探测共用此查找。协调器会断言返回的 id,并在修复或发布状态之前拒绝已存储记录与存活会话的 cwd 不匹配。 -- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话(物化写入与首批事件必须一起提交——二者之间发生崩溃时,不得留下一个已物化但为空的会话;这就是为什么没有单独的 `materialize` 钩子)。 +- `appendBatch(meta, events, isMaterialized)`——持久追加一个连续批次,在尚未物化时原子地惰性物化会话。因此,普通创建不会留下被放弃的已物化空会话。 +- `materializeHeader?(meta)`——为 `SessionPersistence.ensureMaterialized(session)` 显式持久化仅含 header 的会话。它只供把空会话本身视为可恢复持久资源的生命周期前端使用;[标准 ACP 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)是第一个 consumer。支持该生命周期的后端实现此钩子;惰性创建仍是默认行为。 - `commitRepair(meta, tornMarker, closers)`——使崩溃修复持久化:截断损坏的尾部(当且仅当 `tornMarker !== undefined`)并追加 `closers`。**不要求原子性**——JSONL 合理地分两步 fsync(先截断再追加),SQLite 在一个事务中完成 DELETE+INSERT。用于 `prepare`/`load`(截断 + 合成收尾事件)和存活会话接管(仅截断,`closers = []`)。 - `list()`——列出所有已存储的元数据。 - `close?()`——可选的生命周期清理(SQLite 关闭 db 句柄;JSONL 省略),在 dispose effect 中于排空至完全停稳之后被 await,因此 close 失败不会掩盖排空错误。 @@ -44,4 +49,4 @@ Status: implemented ## 后果 -协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 +协调器增加了一层间接、一个不透明的 torn marker、脱离会话生命周期的退役任务,以及有界的已准备 Session 状态,但将此前每个后端重复的、对正确性要求很高的编排逻辑集中到一处。会话 dispose 仍是仅观察事件,因此会话所有者不会等待持久化退役;协调器会收容失败、在存活控制器中保留待处理事件,并以后端 teardown 为完全停稳边界。其钩子面保持窄小:标识校验、接管、碰撞检查、准备与不可变检查共用 `loadStored`;物化保持在 `appendBatch` 内原子完成;列举绕过协调器。读模型使用 `inspect` 而非 `load`,因此观察已持久化但仍开放的轮次时不会提交中断收尾事件;复用、预留与发布由 [Session 准备阶段决策](2026-08-05-session-preparation.zh.md)定义。新后端只需实现存储原语,而无需复制有界写入生命周期。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml index b7e2678928..1e1545bb12 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-branded-ids.md -2026-06-20-branded-ids.md: 29b258b21240c92e74051339f0939a8e70933099 -2026-06-20-branded-ids.zh.md: 288125764d4b279c79b7469080bb0c9f2efdc709 +2026-06-20-branded-ids.md: 6443608c76fe42be74a2b8fe8a27669b09951a49 +2026-06-20-branded-ids.zh.md: f13d999aadf4dba7f2c7d31bb2739deae4a0991f diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md index 29b258b212..6443608c76 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.md @@ -6,13 +6,13 @@ English | [中文](2026-06-20-branded-ids.zh.md) ## Problem -The harness brands `CallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker today. +The harness brands `ToolCallId` (`packages/llm/llm/src/brand.ts`) and the shared agent/session `SessionId` (`packages/core/session/src/types.ts`) using the `Branded = string & { readonly [BRAND]: B }` machinery (owned by the type-only `@deepseek-ai/dsh-brand` package at `packages/util/brand/` — see its [README](../../../../packages/util/brand/README.md)) and a zero-cost cast factory per type. `dsh-brand` also states the governing policy: *"Branding is for ids that cross package boundaries and could plausibly be confused; not every string needs a brand."* That policy is right; the problem is that it is only half-applied. Two gaps let a structurally-identical-but-semantically-wrong string slip through the type checker. **Gap 1 — unbranded cross-boundary IDs in the bash seam.** The background-job id is a plain `string`: `BashTask.id: string` (`packages/shell/shell/src/types.ts`), carried as `string` through the whole executor seam (`ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)` in `packages/shell/shell/src/index.ts`) and validated/passed as `string` by the model-facing tools (`validateJobId`, `assertTaskAccess`, the `job_id` schema arg in `packages/shell/tool-bash/src/index.ts`). It is generated by a per-executor counter — `` `bash-${this.nextTaskId++}` `` in `packages/shell/bash-local/src/index.ts` — which gives it **exactly the same `name-N` shape as `SessionId`'s default** (`` `session-${++counter}` `` in `packages/core/session/src/index.ts`). A bash job id and a session id are trivially swappable at a call site and the compiler says nothing. It is a model-facing id (the model passes `job_id` back to `bash_output`/`bash_kill`), so a confusion here is reachable from untrusted input. The bash **owner token** is the related sub-case: `ShellExecRequest.owner?: string` and `ShellExecSpec.owner: string | undefined` (`packages/shell/shell/src/types.ts`) are documented as a deliberately *opaque* isolation key, but in every live caller the value IS the owning agent's shared `Agent.id`/`SessionId` (`callerToken = (exec) => exec.agent?.id` in `packages/shell/tool-bash/src/index.ts`) wearing a different seam-local name. It is compared for access control (`owner !== callerToken(exec)`), so a mismatched-but-well-typed string here is a cross-session isolation bug the type system currently cannot catch. This is the shared id alias covered by the [unified agent/session identity decision](../simplification/2026-06-20-unify-agent-and-session-id.md). -**Gap 2 — brand erosion at the boundaries of the *already-branded* IDs.** Even `CallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. +**Gap 2 — brand erosion at the boundaries of the *already-branded* IDs.** Even `ToolCallId` and `SessionId` decay back to bare `string` at exactly the places confusion is most likely: registry/store key types and public method params. Representative sites include the session store, the agent registry (both keyed by the shared `SessionId`), tool-presentation call-id maps, ACP's session records, and the persistence coordinator. A brand that is dropped at a collection key buys nothing on lookups — the value of the existing brands is partly unrealized. ## Decision @@ -22,7 +22,7 @@ A type-only change. Brands are zero-cost casts; nothing about runtime behavior, - **Mint a distinct `OwnerToken` brand.** Add `OwnerToken = Branded<'OwnerToken'>` in `packages/shell/shell/src/types.ts`; type `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` as `OwnerToken | undefined`. The `dsh-tool-bash` consumer casts the agent's shared `id` (`SessionId`) into an `OwnerToken` at the boundary — the one place the two vocabularies meet. The bash Service Definition never imports `dsh-session`. (Rationale in the next section.) -- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. +- **Stop the brand erosion.** Propagate the existing brands to the `Map` key types and public method params listed under Gap 2 — `Map`, `Map`, `get(id: SessionId)`, `Map`, ACP's `SessionId` surface, and the coordinator's `Map`. This is the larger mechanical share of the change and the part that makes the *existing* brands actually load-bearing on lookups, not just on struct fields. Illustrative shape (the factory pattern is identical to the three existing brands): @@ -56,11 +56,11 @@ Kept deliberately narrow per the "not every string needs a brand" policy. Each o - **`ToolName`** (the `ToolRuntime` key) — author-defined, human-readable, and rarely confused with another id; the weakest candidate, likely not worth a brand. - **`ErrorCode`** (`HarnessError.code`) — a closed vocabulary (`ABORTED`, `NO_ADAPTER`, …), not a per-instance id; better served by a string-literal union than a brand, if anything. - **Numeric ordinals** — turn number, step number, and the event `seq` are `number`, not `string`, so `Branded` does not apply; a parallel `number & { readonly [BRAND]: B }` variant could brand them, but they are positional ordinals rarely passed across boundaries, so the payoff is low. -- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string today. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision, not bundled into this type-only change. +- **Validated construction** — the brand factories are pure casts with no runtime check, and every boundary (ACP `sessionId`, provider-issued `call.id`, the empty-string fallback in `dsh-llm-deepseek`) trusts the raw string. A `SessionId.parse()` / `isValid()` companion that throws on malformed input at boundaries is a genuine gap, but it is a *runtime-behavior* change with its own design (what is "malformed"? what happens on failure?) and belongs in its own decision, not bundled into this type-only change. ## Verification -The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`CallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts. +The landed invariants: `BashTaskId` and `OwnerToken` are defined in `dsh-shell` and threaded end-to-end (Service Definition, the `dsh-bash-local` generation site, the `dsh-tool-bash` model-facing tool) with no `dsh-shell` dependency on `dsh-session`; no collection keyed by an in-scope branded id (`ToolCallId`/`SessionId`/`BashTaskId`) is keyed by bare `string`; public method params and exported signatures keep the brand; and brands are constructed via the cast factory at each boundary where a raw string enters (provider call id, ACP session id, model-supplied `job_id`), never as scattered `as` casts. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md index 288125764d..f13d999aad 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-branded-ids.zh.md @@ -6,13 +6,13 @@ Status: implemented ## 问题 -harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `CallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 今天仍能通过类型检查器。 +harness 使用 `Branded = string & { readonly [BRAND]: B }` 机制,为 `ToolCallId`(`packages/llm/llm/src/brand.ts`)和 agent(智能体)/会话共享的 `SessionId`(`packages/core/session/src/types.ts`)做 brand 处理;该机制由纯类型包 `@deepseek-ai/dsh-brand` 拥有,位于 `packages/util/brand/`,见其 [README](../../../../packages/util/brand/README.zh.md),并为每个类型提供零开销的 cast 工厂。`dsh-brand` 还声明了治理策略:*「Branding 用于跨包边界且可能被混淆的 id;不是每个 string 都需要 brand。」* 这条策略是正确的;问题在于它只落实了一半。两处缺口使得结构相同但语义错误的 string 仍能通过类型检查器。 **缺口 1:bash seam 中未 brand 的跨边界 ID。** 后台 job id 是普通 `string`:`BashTask.id: string`(`packages/shell/shell/src/types.ts`),作为 `string` 贯穿整个执行器 seam(`packages/shell/shell/src/index.ts` 中的 `ShellExecutor.get`/`ownerOf`/`readOutput`/`kill(id: string)`),再由面向模型的工具以 `string` 校验并传递(`validateJobId`、`assertTaskAccess`、`packages/shell/tool-bash/src/index.ts` 中 `job_id` 的 schema 参数)。它由每执行器计数器生成——`packages/shell/bash-local/src/index.ts` 中的 `` `bash-${this.nextTaskId++}` ``——其形状与 `SessionId` 的默认值**完全相同,都是 `name-N`**(`packages/core/session/src/index.ts` 中的 `` `session-${++counter}` ``)。bash job id 和会话 id 在调用点轻易就能互换,而编译器毫无反应。它是面向模型的 id(模型会把 `job_id` 传回 `bash_output`/`bash_kill`),所以该混淆可由不受信任的输入触达。 -bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)覆盖的共享 id 别名。 +bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` 和 `ShellExecSpec.owner: string | undefined`(`packages/shell/shell/src/types.ts`)被文档描述为刻意*不透明*的隔离键,但在所有实际调用方中,该值就是所属 agent 共享的 `Agent.id`/`SessionId`(`callerToken = (exec) => exec.agent?.id`,位于 `packages/shell/tool-bash/src/index.ts`),只是披着另一个 seam 本地名称。它被用于访问控制比较(`owner !== callerToken(exec)`),因此一个不匹配但类型正确的 string 在此处就是跨会话隔离 bug,而当前类型系统无法捕获。这正是[统一 agent/session 标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)覆盖的共享 id 别名。 -**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `CallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 +**缺口 2:*已经 brand* 的 ID 在边界处被侵蚀。** 就连 `ToolCallId` 和 `SessionId` 也恰好在最容易混淆的地方退化为裸 `string`:注册表/store 键类型和公开方法参数。代表性位置包括会话存储、agent 注册表(二者都以共享的 `SessionId` 为键)、工具展示层的 call-id map、ACP(Agent Client Protocol)的会话记录,以及持久化协调器。在集合键处丢弃 brand,会让既有 brand 在查找时毫无价值;它们的价值只实现了一部分。 ## 决策 @@ -22,7 +22,7 @@ bash **owner token** 是相关的子情形:`ShellExecRequest.owner?: string` - **铸造独立的 `OwnerToken` brand。** 在 `packages/shell/shell/src/types.ts` 中添加 `OwnerToken = Branded<'OwnerToken'>`;将 `ShellExecRequest.owner` / `ShellExecSpec.owner` / `ShellExecutor.ownerOf` 的类型标注为 `OwnerToken | undefined`。`dsh-tool-bash` 消费方在边界处将 agent 共享的 `id`(`SessionId`)cast 为 `OwnerToken`——这是两套词汇唯一交汇的地方。bash Service Definition 从不导入 `dsh-session`。(理由见下一节。) -- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map`、`Map`、`get(id: SessionId)`、`Map`、ACP 的 `SessionId` surface、协调器的 `Map`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 +- **阻止 brand 侵蚀。** 将既有 brand 传播到缺口 2 列出的 `Map` 键类型和公开方法参数中:`Map`、`Map`、`get(id: SessionId)`、`Map`、ACP 的 `SessionId` surface、协调器的 `Map`。这是变更中机械量最大的部分,也是让*既有* brand 在查找处真正发挥作用(而不仅仅标注在结构体字段上)的关键。 示意形状(工厂模式与已有的三个 brand 完全一致): @@ -56,14 +56,14 @@ export function OwnerToken(id: string): OwnerToken { - **`ToolName`**(`ToolRuntime` 的键):由作者定义、人类可读,且很少与其他 id 混淆;最弱的候选,可能不值得加 brand。 - **`ErrorCode`**(`HarnessError.code`):一个封闭词汇(`ABORTED`、`NO_ADAPTER`……),不是逐实例的 id;如果要做,string 字面量联合类型比 brand 更合适。 - **数值序号**:轮次号、步骤号和事件 `seq` 是 `number` 而非 `string`,`Branded` 不适用;可以用并行的 `number & { readonly [BRAND]: B }` 变体来 brand 它们,但它们是位置序号、很少跨边界传递,收益较低。 -- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)今天都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。 +- **带校验的构造**:brand 工厂是纯 cast,无运行时检查,且每个边界(ACP `sessionId`、提供方签发的 `call.id`、`dsh-llm-deepseek` 中的空字符串回退)都信任裸 string。一个在边界处对格式错误的输入抛异常的 `SessionId.parse()` / `isValid()` 配套工具确实是缺口,但它是*运行时行为*变更,有自己的设计问题(什么算「格式错误」?失败时会怎样?),应在独立决策中处理,不应捆绑进这次纯类型变更。 ## 验证 -已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`CallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 +已落地的不变式如下:`BashTaskId` 和 `OwnerToken` 定义在 `dsh-shell` 中,并端到端贯穿 Service Definition、`dsh-bash-local` 生成点与 `dsh-tool-bash` 面向模型的工具,且 `dsh-shell` 未添加对 `dsh-session` 的依赖;没有任何以范围内 brand id(`ToolCallId`/`SessionId`/`BashTaskId`)为键的集合使用裸 `string`;公开方法参数和导出签名保留 brand;每个原始 string 进入的边界(提供方 call id、ACP 会话 id、模型提供的 `job_id`)都通过 cast 工厂构造 brand,而不是散落的 `as` cast。 ## 后果 -- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 +- **两个接口面的机械性改动。** 传播 brand 涉及 bash seam(Service Definition + Service Provider + Consumer)以及 ACP 会话 id 接口和持久化协调器。改动面广但严重度低:遗漏的位置是编译错误而非静默 bug。从可观察行为看,这是一项纯类型变更——无快照或 e2e 行为差异。它与[统一 agent/会话标识决策](../simplification/2026-06-20-unify-agent-and-session-id.zh.md)相邻,因为二者都触及会话 id / owner-token 边界;`OwnerToken` 出于上述解耦理由仍与统一后的 id 保持独立。 - **Brand 不做校验。** Brand 是混淆防护,不是正确性证明:一个*错误的*会话 id 只要仍是格式正确的 string,就和以前一样能通过类型检查器。本决策不关闭这个缺口(见「不在范围内」)——它只阻止这类*类别*错误:传入错误*种类*的 id。 - **「在哪里停下」仍是判断题。** 为 `BashTaskId` 加 brand 但不为 `ToolName` 加,为 `OwnerToken` 加但不为 `ModelId` 加,是对哪些 string「可能被混淆」的品味判断。合理的评审者可能想要更多或更少;`brand.ts` 中的策略是裁决依据,本决策倾向于面向模型或用于访问控制的 id。 diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml index 477c6df3ae..7d4ccbd4e1 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md 2026-06-20-generic-long-running-tool-runtime.md: 7db43323dd83f8e99698a7163412c9b33a607dfb -2026-06-20-generic-long-running-tool-runtime.zh.md: efd306cc8d3b82749635dde302af45235c27b431 +2026-06-20-generic-long-running-tool-runtime.zh.md: 040ea6a01e97c9dc1d93e8f3a0fd9c1f4b0f31fc diff --git a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md index efd306cc8d..040ea6a01e 100644 --- a/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md @@ -19,13 +19,13 @@ Status: implemented 长时间运行工具是生产方。`dsh-tool-bash` 将 `ShellProcess` 适配为增量输出与进程取消;`dsh-tool-subagent` 将子运行适配为最终输出与子运行释放。bash 与 subagent 能力 seam 保持独立,不依赖会话或任务注册表。 -`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)中)。 +`JobRegistry` 是 `@deepseek-ai/dsh-jobs` 中的 Service Definition;进程内 Service Provider 是 `@deepseek-ai/dsh-jobs-local` 中的 `LocalJobRegistry`(该拆分记录在[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)中)。 ## 运行时约定 -字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。 +字面类型见[任务子系统页面](../../../../docs/subsystems/jobs.zh.md)。生产方调用 `ctx.jobs.start()`,传入 kind、label、可选的所属 `Agent`、可选的正数 `outputLimitBytes` 与一个 `run()` 函数。运行时会在调用 `run()` 前完成所有可能失败的预检工作,并且只调用一次。`run()` 返回钩子后,注册过程不会再执行可能失败的步骤而直接提交;生产方无法启动没有可收集 job id 的工作。 -进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 +进程内 Service Provider 还拥有有界准入,其理由记录在[有界后台任务准入决策](../bug-fix/2026-08-11-bounded-background-job-admission.zh.md)中。它的 `maxConcurrentJobsPerOwner` 配置必须是正的安全整数,默认值为 `10`;`start()` 从 `running` 与 `stopping` 记录派生每个确切 `Agent` 对象的活动数量,而全部无 owner 任务共享一个服务级桶。容量拒绝发生在 `run()` 与 id 分配之前,处于 stopping 的任务只有在生产方 `done` 结算时才释放名额。Service Provider 不排队或抢占任务,也不保留第二份可变计数。 `outputLimitBytes` 是生产方拥有的呈现策略,而非注册表缓冲区。注册表校验该值,并将其原样投影到 `JobSnapshot`;通用任务控制器添加自身的状态或通知元数据后,再将该上限应用于完整的面向模型输出。省略该值时保持现有控制器行为,因此运行时不会向无关的生产方类别施加隐式默认值。 @@ -79,7 +79,7 @@ job id 在运行时全局可见且可预测,因此注册表会授权每次访 流式读取共享一个任务作用域内的消费游标,因为所属模型是预期读取方。UI 或多个独立读取方需要单独的非消费式观察 API;共享该游标会让读取方彼此消费对方的输出。 -系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.md)负责。 +系统提示词要求模型保留 job id、在后台工作运行时继续处理独立工作而非忙轮询或重复启动同一任务、在给出最终答案前收集相关任务,并终止不再重要的工作。完成时,系统会向确切所有者的会话交付一条已记录的消息:繁忙的所有者走注入,空闲的所有者会被唤醒,其有界策略由[空闲所有者唤醒决策](../feature/2026-08-11-background-job-completion-wakes-an-idle-owner.zh.md)负责。 当读取或等待交付终止任务、尚在等待的等待方在结算时认领了投递,或模型显式终止任务时,运行时将终止任务标为 `reported`。已报告的任务不会注入冗余的完成通知。监听器失败会独立记录,不会阻止后续监听器,也不会被等待方或资源销毁过程等待。当快照携带 `outputLimitBytes` 时,`dsh-tool-jobs` 会保持 UTF-8 边界,并复用生产方已有的截断标记,而不会重复添加。读取会为状态后缀预留空间并保留输出尾部;完成通知会先为稳定的 `background job ` 前缀与 `job_output` 指令预留空间,再截断可变的 kind、label、status、detail,乃至截断标记本身,因此 PTY 的最小上限仍能标识需要收集的任务。任务控制器在策略有机会拒绝或短路分发之前,于最先执行的 pre-execute 监听器中解析调用方可见的生产方上限;随后通过任务定义最后一道的 `finalizeContent` 回调应用该上限,使规范化的工具错误、外层流水线失败与单文本策略结果都无法绕过该边界;经特意结构化的多块策略结果仍由策略拥有其形状与大小。 @@ -105,7 +105,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ### 立即抽象任务运行时后端 -当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 +当前 `JobStart.run()` 约定传入进程内回调与确切的 `Agent` 对象。持久化后端会改变身份、重启、所有权与观察语义,因此在引入之时注册表保持为单一具体服务,而非固化错误的边界。[任务注册表约定 Agent Note](2026-07-26-job-registry-seam.zh.md)后来在不改变这些进程内语义的前提下,将约定与进程内实现分离。 ### 由消费方负责授权或清理事件 @@ -131,7 +131,7 @@ bash seam 暴露 `resolve`、`run` 和 `start`。`start(spec)` 返回一个 `She ## 后果 -bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.md)将生产方指向本约定。 +bash 命令与 subagent 共享一套 id 词汇、列表、通知格式、提示词习惯和控制工具。新的长时间运行生产方只需实现执行钩子,而不必再实现一套注册表与工具族。[工具实操手册](../../../../docs/cookbook/adding-a-tool.zh.md)将生产方指向本约定。 单个确切 owner 无法再无限增加进程内由 Task 承载的工作,另一个 owner 也不会消耗它的额度。取消请求会继续占用容量,直到生产方真正释放资源,因此用新工作替换缓慢停止的任务不会突破已配置的实时资源预算。 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 f9b830c3b7..dcaeb8c34b 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.md -2026-06-21-bounded-llm-request-recovery.md: 3a91d77fe9c08fbcc3afdce4dad9a38e288b4723 -2026-06-21-bounded-llm-request-recovery.zh.md: a6b342b19236251e638242c1e9df6b3d6ed557c0 +2026-06-21-bounded-llm-request-recovery.md: e725a025f2d8b0d5e8eaf4137f07d8eab4448bf4 +2026-06-21-bounded-llm-request-recovery.zh.md: 9e2263b05888797eaaaeb82859730d1c4a728cea 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 3a91d77fe9..e725a025f2 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,13 +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. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism. +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 failed-attempt recovery boundary, normal mode's transient defaults, visible single attempts, and durable retry status. [Terminal LLM stream failures](2026-07-29-terminal-llm-stream-failures.md) supersedes its thrown-error identity and stream-sidecar mechanism. ## Problem Provider adapters can fail by throwing during dispatch or iteration or by ending with `finish { kind: 'error' | 'aborted' }`. The final adapter boundary normalizes thrown values to that terminal finish protocol before `dsh-agent-loop` receives them; middleware and result-processing defects remain thrown. The loop offers a terminal model-request failure to `agent/request-error`. 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 turn 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 reconstructs its next attempt 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. @@ -52,7 +52,7 @@ 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` waterfall 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-compaction-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 `agent/request-error` waterfall carries the current `LlmFailure`, an immutable list of prior failures that authorized retries 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-compaction-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 [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). @@ -68,7 +68,7 @@ The agent-spine demo bundle loads the plugin so the shared stdio/TUI, one-shot C ### Make one layer own visible attempts -Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by a closed failed step plus `llm/retry`. +Adapters perform one provider request per `stream()` call. The pi-ai adapter removes public `maxRetries` and `maxRetryDelayMs` profile fields and disables library retries; the hand-written adapter keeps its current single-attempt behavior. This prevents an SDK budget from multiplying the agent budget and ensures every transient retry is represented by its recorded failed attempt plus `llm/retry`. `ctx.llm.stream()` remains the raw one-attempt waterfall. Direct callers such as compaction summarization receive the structured failure but do not gain automatic retry, because they have no agent step boundary or general durable place to separate attempts. A future direct-call consumer may justify a buffering helper that retries only before emitting a chunk; this decision adds no such helper. @@ -82,9 +82,9 @@ 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 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. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects consecutive retry-turn events into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from subsequent turn facts. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. +A failed attempt may leave `assistant/chunk` events in its step, but it never appends `assistant/message` and never dispatches a tool. A retry continues inside the failing turn and step, reconstructs the request from the durable surface, and produces its own chunks; only the final outcome closes the turn. UIs may render live chunks while a step is open, then mark or clear that transient view when `llm/retry` identifies the failed attempt or `turn/end` records failure. Web validates the complete retry payload contract, clears the failed partial at `llm/retry`, projects each producer-correlated `retryId` chain into one stable row updated to the latest attempt, and derives scheduled, started, or cancelled status from `llm/retry-started` and the owning turn and step boundaries' closure. Its countdown anchors the scheduled delay to browser receipt rather than the Host event clock, uses ceiling-rounded seconds with a one-second floor, animates only while unresolved, and keeps exact latest failure details collapsed behind the row. Retry nodes anchor their own trajectory turn even when the failed attempt has no assistant node. Message derivation continues to ignore the failed chunks, and Web applies the same projection during history rebuild so refreshing cannot resurrect discarded partials or duplicate retry rows. -If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. If transient recovery continues, `llm/retry` is the durable home for that attempt's failure and delay, so its failed turn does not also gain a terminal error row. No standalone final-error event or response-id vocabulary is added. +If recovery is exhausted, the final failure is stored once on `turn/end.reason` with the structured facts. Web derives one `turn-error` node at that sequence position and renders its display-safe message and optional code inline; AUTH projections replace provider copy that may echo credential fragments with `API key is invalid`, while the raw diagnostic remains in the session log. The same fold runs for live events and history replay. While transient recovery continues, `llm/retry` is the durable home for each intermediate failure and delay; the terminal row exists only once `turn/end` records the error, and because exhausted recovery shares the failing turn, the turn's retry history never suppresses that row — the settled retry chain and the terminal error render side by side. No standalone final-error event or response-id vocabulary is added. ## Out of scope @@ -114,15 +114,15 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - 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 hooks, 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 turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-basic` context-overflow recovery. +- Real agent-loop tests cover failure before chunks, partial chunks then failure, thrown and in-band failures, retry to success inside the same turn, exhaustion to structured `turn/end.reason`, and composition with `dsh-compaction-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 records its own chunk seqs and provider/model route. -- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, a real Web composition test covers partial transport failure through recovery, and ACP automation snapshots confirm that a discarded attempt stays off the wire while the recovered reply is emitted. +- The plugin-owned `llm/retry` event is non-surface, survives JSONL and SQLite round trips, is ignored by message derivation, and drives TUI and Web retraction plus scheduled-retry rendering. Client tests cover complete wire validation, clock-independent countdown, cancellation versus completed retry labels, and trajectory attribution; keyless UI snapshots cover Web scheduling and success, real Web composition tests cover partial transport failure through recovery and exhausted recovery's terminal error row beside the settled retry chain, and 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. - Direct `ctx.llm.stream()` callers remain single-attempt and receive the same structured failure facts. ## Consequences -- 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. +- Every retry attempt is visible inside its owning turn as the failed attempt's chunks 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. @@ -136,3 +136,4 @@ If recovery is exhausted, the final failure is stored once on `turn/end.reason` - [Timeout deadline library](../../implemented/architecture/2026-07-06-timeout-deadline-library.md) separates shared deadline classification from capability-owned termination. - [After-call compaction pressure and context-overflow recovery](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md) owns the current closed-step request-recovery extension point and bounded overflow retry. - [Provider-routed LLM adapters](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md) owns explicit provider/model routing and the one-adapter-per-provider invariant. +- [Terminal turn errors survive same-turn retry history](../bug-fix/2026-08-20-turn-error-survives-same-turn-retry-history.md) owns the removal of the Web retry-history suppression that hid exhausted recovery's terminal error row. 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 a6b342b192..9e2263b058 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,13 +4,13 @@ Status: implemented [English](2026-06-21-bounded-llm-request-recovery.md) | 中文 -[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、已关闭步骤的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)取代了其中关于抛出错误身份和流 sidecar 的机制。 +[按提供方配置的请求重试策略](../feature/2026-07-24-provider-retry-policies.zh.md)在此基础上增加了确切提供方配置与显式无界 mode。本说明继续负责结构化失败事实、失败尝试的恢复边界、normal mode 的暂时性默认值、可见的单次尝试和持久重试状态。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)取代了其中关于抛出错误身份和流 sidecar 的机制。 ## 问题 -提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回约定。 +提供方适配器可能在分发或迭代时抛出异常,也可能以 `finish { kind: 'error' | 'aborted' }` 结束。最终适配器边界会在 `dsh-agent-loop` 接收前把抛出值规范化为该终止 finish 协议;middleware 与结果处理缺陷仍会抛出。loop 会将终止模型请求失败交给 `agent/request-error`。未被处理的失败是终态;处理失败的监听器修复策略自有状态,返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回约定。 -该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志开启新的编号轮次。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 +该边界已能安全地再次发起请求。原始 `assistant/chunk` 事件携带失败的 `turn` 和 `step`;除非某条成功的 `assistant/message` 引用这些事件,否则消息派生会忽略它们。只有终止性 finish 成功且组装完成后,系统才会分发工具调用;重试则会从持久日志重建下一次尝试。因此,harness 无需引入第二套响应生命周期或暂定输出协议,即可分隔两次尝试。 此前的边界还留有三个较窄的缺口。 @@ -46,15 +46,15 @@ agent loop(智能体循环)会将终止 finish 的 `LlmFailure` 传给 `agen 适配器会先提取结构化事实,再回退到消息检查。它们会验证 HTTP 状态,将 `Retry-After` 的秒数或日期解析为正的有限毫秒延迟,在提供方公开请求 id 时将其品牌化,并区分自身超时与调用方中止。提供方专用 code 和消息可以细化映射,但恢复监听器不会解析它们。 -共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 +共享的暂时性 code 集有意保持很小:适配器针对 `RATE_LIMIT` 和 `SERVER` 的映射,远程失败使用的显式 `TIMEOUT` 和 `TRANSPORT` code,以及提供方响应已完成却没有内容块时使用的 `EMPTY_RESPONSE`。两个适配器都会把最后一种情况归类为错误 finish;详见[空模型响应可重试](../bug-fix/2026-07-24-empty-model-response-is-retryable.zh.md)。身份验证、配额、无效请求、上下文溢出、协议、中止和未知失败都保留不同的稳定 code,且默认不属于暂时性失败。新增 code 需要适配器 fixture(测试前置数据)和已记录的策略决策;无需扩展第二个失败类枚举。 ### 将重试策略放在现有失败步骤扩展点上 `@deepseek-ai/dsh-llm-retry` 是监听 `agent/request-error` 的函数插件。它不引入服务或新的循环分支;agent-loop 包仅会更改通过现有失败步骤恢复控制流携带的数据。 -`agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试轮次的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 +`agent/request-error` waterfall 携带当前 `LlmFailure`、在连续恢复序列中授权重试的不可变先前失败列表,以及提供服务的注册项所携带的不可变重试策略。循环只传递而不解释该策略;它拥有连续失败历史,并在模型请求成功后清除。`dsh-llm-retry` 的 normal 策略统计由同一项确切提供方策略安排的持久重试记录,`dsh-compaction-basic` 则维护自己的上下文溢出预算。因此,暂时性失败与上下文溢出交替出现时,会各自独立消耗其有限预算;最大请求数等于 1 加上所有已加载有限预算之和。 -当前配置形状由[提供方策略决策](../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)。 +当前配置形状由[提供方策略决策](../feature/2026-07-24-provider-retry-policies.zh.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`;提供方延迟不加抖动。 @@ -68,7 +68,7 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 由单一层负责可见的尝试 -适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由一个已关闭的失败步骤加 `llm/retry` 表示。 +适配器每次调用 `stream()` 只执行一次提供方请求。pi-ai 适配器移除公开的 `maxRetries` 和 `maxRetryDelayMs` profile 字段,并禁用库内部重试;手写适配器保持现有的单次尝试行为。这样既避免 SDK 预算成倍放大 agent 预算,又能确保每次暂时性重试都由其记录在案的失败尝试加 `llm/retry` 表示。 `ctx.llm.stream()` 仍是原始的单次尝试 waterfall。压缩(compaction)摘要等直接调用方会收到结构化失败,但不会自动获得重试,因为它们没有 agent 步骤边界,也没有可供分隔尝试的通用持久位置。未来的直接调用消费方可能会需要一个缓冲辅助函数,仅在尚未发出任何分片时重试;本决策不增加此类辅助函数。 @@ -82,9 +82,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ### 在现有日志中分隔尝试 -一次失败尝试可以在已关闭的步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试会关闭失败轮次,开启下一个编号轮次,从持久表层重建请求,并生成自己的分片。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败步骤,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将连续重试轮次的事件投影为稳定的一行,并用最新一次尝试更新该行,再从后续轮次事实派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 +一次失败尝试可以在其步骤中留下 `assistant/chunk` 事件,但绝不会追加 `assistant/message`,也不会分发工具。重试在失败的轮次与步骤内继续,从持久表层重建请求,并生成自己的分片;只有最终结果才会关闭该轮次。步骤仍处于打开状态时,UI 可以渲染实时分片;当 `llm/retry` 标识失败尝试,或 `turn/end` 记录失败时,UI 再标记或清除这份暂时视图。Web 会验证完整的重试载荷约定,在 `llm/retry` 到达时清除失败的部分输出,将每条生产方关联的 `retryId` 重试链投影为稳定的一行,并用最新一次尝试更新该行,再从 `llm/retry-started` 与所属轮次、步骤边界的关闭派生 scheduled、started 或 cancelled 状态。倒计时以浏览器收到事件的时刻为计划延迟的起点,而不是使用 Host 事件时钟;它按向上取整且不低于 1 秒的秒数显示,仅在重试尚未结束时显示动画,并把最近一次失败的准确详情折叠在该行之后。即使失败尝试没有 assistant 节点,重试节点也会锚定自身的轨迹轮次。消息派生仍会忽略失败分片;Web 在重建历史时也会应用同一投影,因此刷新页面不会让已丢弃的部分输出重新出现,也不会生成重复的重试行。 -如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。如果暂时性恢复继续,`llm/retry` 就是该次尝试的失败与延迟的持久归属位置,因此该失败轮次不会再获得终态错误行。本决策不增加独立的最终错误事件或响应 id 词汇。 +如果恢复预算耗尽,最终失败会连同结构化事实在 `turn/end.reason` 中存储一次。Web 会在该序列位置派生一个 `turn-error` 节点,并内联渲染适合展示的消息与可选错误码;AUTH 投影会把可能回显凭据片段的提供方文案替换为 `API key is invalid`,原始诊断仍保留在会话日志中。实时事件和历史回放使用同一套折叠逻辑。暂时性恢复继续期间,`llm/retry` 是每次中间失败与延迟的持久归属位置;终态错误行只在 `turn/end` 记录错误后才存在,而由于耗尽的恢复与失败共享同一轮次,该轮次的重试历史绝不会抑制这一行——定格的重试链与终态错误并列渲染。本决策不增加独立的最终错误事件或响应 id 词汇。 ## 不在范围内 @@ -100,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 作为默认值。 +- **只要调用方仍处于活跃状态就无限重试**:[按提供方配置的策略](../feature/2026-07-24-provider-retry-policies.zh.md)对显式 `always` 配置项推翻了这项拒绝,同时保留有界的 normal mode 作为默认值。 - **只通过进程 logger 记录重试状态**:拒绝采用,因为进程日志无法重建会话行为,也不能驱动回放后的 UI 状态。 - **只保留扁平 code**:拒绝采用,因为重试延迟和提供方请求 id 是结构化的提供方事实,而当不同协议失败共用一个稳定 code 时,诊断还需要 HTTP 状态。 @@ -114,15 +114,15 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 - 每个提供方适配器都在 Loader 启动时验证其嵌套重试策略,`ctx.llm` 则将该策略与路由一同捕获;normal mode 会委托不合格路径,而且在没有其他策略时最多发起 `maxRetries + 1` 次提供方请求。 - 退避期间执行 HMR 的测试证明:dispose 过程会注销监听器、中止并等待其捕获的回调,dispose 后不发出重试决策,也不留下存活的定时器或 promise。 - 纯单元测试覆盖暂时性 code 选择、指数退避和抖动边界、有效及超出上限的 `Retry-After`、耗尽的预算、确定性定时器/随机数钩子,以及退避期间中止。 -- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在新轮次中重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。 +- 真实 agent-loop 测试覆盖分片前失败、部分分片后失败、抛出及带内失败、在同一轮次内重试至成功、耗尽后写入结构化 `turn/end.reason`,以及与 `dsh-compaction-basic` 上下文溢出恢复的组合。 - 部分分片集成测试证明:失败分片仍归属于失败步骤,该步骤不会提交 assistant 消息或工具副作用,成功的重试会记录自己的分片 seq 和提供方/模型路由。 -- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 +- 插件拥有的不进入表层的 `llm/retry` 事件可在 JSONL 和 SQLite 往返后保留,被消息派生忽略,并驱动 TUI 和 Web 撤回及计划重试渲染。客户端测试覆盖完整的 wire 验证、独立于时钟的倒计时、已取消与已完成重试标签的区别以及轨迹归属;无密钥 UI 快照覆盖 Web 的调度与成功,真实 Web 组合测试覆盖部分传输失败直至恢复,以及耗尽后终态错误行与定格重试链并列的画面,ACP 自动化快照确认,被丢弃的尝试不会通过协议发出,而恢复后的回复会正常发出。 - 空闲看门狗测试证明:只有 `next()` 尚未完成时才会重新布防稳定信号;在消费方思考期间及 `finally` 中会解除布防;它与总调用 deadline 以及更早发生的调用方中止分开分类。适配器测试证明该信号会终止底层请求,而不只是与其脱离。 - `ctx.llm.stream()` 的直接调用方仍只尝试一次,并收到相同的结构化失败事实。 ## 后果 -- 每次重试尝试都以一个已关闭失败轮次加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。 +- 每次重试尝试都在其所属轮次内以失败尝试的分片加 `llm/retry` 的形式可见,适配器级的单次尝试行为会防止隐藏的 SDK 重试成倍增加策略决策。即使没有分片到达,重试仍可能造成提供方重复计费;normal mode 会限制此风险,而显式 always mode 会接受它,直至取消或成功。 - 提供方 SDK 可能隐藏状态或重试标头。适配器会保留 SDK 公开的稳定事实,否则使用粗粒度 code,而不会让恢复策略解析脆弱的文本。 - 持久重试事件扩展了会话协议和 UI 状态机。事件与其消费方一同交付,可避免产生无人使用的遥测词汇;但以后更改 schema 仍需要同步完成持久化和回放工作。 - 清除失败步骤的实时分片可能会明显撤回输出。与把丢弃的文本或不完整工具 JSON 呈现为已提交历史相比,这是更好的选择;快照固定这一转换。 @@ -131,8 +131,9 @@ agent-spine 演示组合包加载该插件,因此共享的 stdio/TUI、一次 ## 相关资料 -- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.md)负责稳定、可供机器路由的 code 与 cause chaining。 -- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.md)使提供方/模型和完整请求输入在分发前持久化。 -- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 -- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。 -- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 +- [结构化错误分类体系](../../implemented/architecture/2026-06-11-structured-error-taxonomy.zh.md)负责稳定、可供机器路由的 code 与 cause chaining。 +- [可重建请求](../../implemented/architecture/2026-07-05-reconstructable-requests.zh.md)使提供方/模型和完整请求输入在分发前持久化。 +- [超时 deadline 库](../../implemented/architecture/2026-07-06-timeout-deadline-library.zh.md)将共享的 deadline 分类与能力自身拥有的终止操作分开。 +- [调用后压缩压力与上下文溢出恢复](../../implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md)负责当前已关闭步骤的请求恢复扩展点与有界溢出重试。 +- [提供方路由的 LLM 适配器](../../implemented/architecture/2026-07-14-provider-routed-llm-adapters.zh.md)负责显式提供方/模型路由与每个提供方仅有一个适配器的不变量。 +- [Terminal turn errors survive same-turn retry history](../bug-fix/2026-08-20-turn-error-survives-same-turn-retry-history.zh.md)负责移除曾藏掉耗尽恢复终态错误行的 Web 重试历史抑制。 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 687743c0a4..2b6c63d334 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 479d3a46dc41c5cc9ae9b77b81dbef3d6524370b -2026-06-21-mandatory-app-attribution-headers.zh.md: 75162604623099d50ca87aafd5d45567e2121789 +2026-06-21-mandatory-app-attribution-headers.md: 9e0c029dc03c722512680a563c24e470128ee322 +2026-06-21-mandatory-app-attribution-headers.zh.md: 1427daf8f6065ec2dee324075a8381a625d9e960 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 479d3a46dc..9e0c029dc0 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -42,7 +42,7 @@ Wire mapping (`attributionHeaders`; header names lowercase in code - HTTP field |---|---| | All HTTP-based adapters | `User-Agent: {product}/{version} (+{url})` - the parenthesized `+url` comment stays within RFC 9110's conservative product/comment syntax. | | Direct DeepSeek endpoint | `User-Agent` for app attribution; `x-deepseek-harness-user-id` and conditional `x-deepseek-harness-session-id` are separate request identity under the DeepSeek-specific decision. Do not send OpenRouter-only headers unless DeepSeek documents an equivalent contract. | -| OpenRouter endpoints | `User-Agent` only for now. Do not send `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, or `X-OpenRouter-Categories` under this decision. | +| OpenRouter endpoints | `User-Agent` only. This decision excludes `HTTP-Referer`, `X-OpenRouter-Title`, `X-Title`, and `X-OpenRouter-Categories`. | | Future providers | `User-Agent` only unless a later provider-specific Agent Note accepts additional headers. Do not reuse `HTTP-Referer` by analogy. | Endpoint detection is not part of this Agent Note because no endpoint-specific mapping is accepted here. If OpenRouter support lands later, detection must be explicit: either a dedicated OpenRouter provider package or an explicit `provider: 'openrouter'` / `attributionTarget: 'openrouter'` config, not arbitrary path fragments or model names. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 7516260462..1427daf8f6 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。 +LLM(大语言模型)提供方请求应当标识发出请求的产品。这对提供方侧的技术支持、滥用调查、兼容性调试和流量分析都有价值。在本 Agent Note 之前,harness 只做了部分工作:手写的 DeepSeek 适配器发送了一个手动复制的 `User-Agent` 常量(`packages/llm/llm-deepseek/src/adapter.ts`),而基于 pi-ai 的孪生适配器则完全不发送 harness 自有的头部(`packages/llm/llm-pi-ai/src/adapter.ts`)。因此新适配器可以悄无声息地省略归属标识,而基于库的适配器也可能与手写适配器产生偏差——尽管[孪生适配器 Agent Note](2026-06-13-twin-llm-adapters.zh.md) 的存在正是为了确保两种实现中的提供方约定真实可靠。 直接触发因素来自 OpenRouter 的[应用归属](https://openrouter.ai/docs/app-attribution)文档。OpenRouter 根据 `HTTP-Referer` 加上用于展示和分类的头部来创建应用页面和排名。这有价值,但它不是 HTTP 标准中的应用身份机制。风险在于:把 OpenRouter 的精确头部集当作通用标准来采纳,然后将提供方特有的头部泄漏到直连 DeepSeek 的请求、未来的 OpenAI/Anthropic/Vertex 适配器、测试服务器或无限期记录未知字段的代理中。 @@ -24,7 +24,7 @@ LLM(大语言模型)提供方请求应当标识发出请求的产品。这 ## 决策 -在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.md)另行负责其用户与会话头部。 +在 LLM 适配器边界,提供方无关的应用归属是强制的,且仅使用标准 `User-Agent` 头部。规则:每个产品级 LLM 适配器在每个提供方 HTTP 请求上发送一个静态、非机密的应用身份,且每个适配器都有测试证明 `User-Agent` 到达了线路(mock 服务器断言收到的头部;对于基于库的适配器,通过库的头部钩子馈入同一个 mock 服务器断言)。这条规则约束应用归属,不约束提供方特有的请求身份;[DeepSeek 请求身份决策](../feature/2026-08-11-deepseek-request-user-id-header.zh.md)另行负责其用户与会话头部。 OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 和 `X-OpenRouter-Categories` 是 OpenRouter 特有的产品展示头部,不是提供方无关的模型请求归属。它们可以后续由 OpenRouter 适配器或显式 OpenRouter 模式提出,附带自己的隐私/产品决策、测试和文档。在此之前,即使请求指向 OpenRouter,也只发送本决策定义的共享 `User-Agent` 归属。 @@ -42,7 +42,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` |---|---| | 所有基于 HTTP 的适配器 | `User-Agent: {product}/{version} (+{url})`——括号中的 `+url` 注释符合 RFC 9110 保守的 product/comment 语法。 | | 直连 DeepSeek 端点 | `User-Agent` 用于应用归属;`x-deepseek-harness-user-id` 与条件性的 `x-deepseek-harness-session-id` 由 DeepSeek 特有决策作为独立请求身份管理。除非 DeepSeek 文档化了等效约定,否则不发送 OpenRouter 特有头部。 | -| OpenRouter 端点 | 目前仅 `User-Agent`。本决策下不发送 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 或 `X-OpenRouter-Categories`。 | +| OpenRouter 端点 | 仅发送 `User-Agent`。本决策排除 `HTTP-Referer`、`X-OpenRouter-Title`、`X-Title` 与 `X-OpenRouter-Categories`。 | | 未来提供方 | 仅 `User-Agent`,除非后续提供方特有的 Agent Note 接受额外头部。不要类比复用 `HTTP-Referer`。 | 端点检测不在本 Agent Note 范围内,因为此处不接受任何端点特有的映射。如果后续支持 OpenRouter,检测必须是显式的:要么是专门的 OpenRouter 提供方包,要么是显式的 `provider: 'openrouter'` / `attributionTarget: 'openrouter'` 配置,而非任意路径片段或模型名称。 diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml index bc92549f44..bbdb39100a 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md -2026-06-24-web-capability-seam.md: 7e7b09f19864bd2ad8ad9d69579c1d5c79600cde -2026-06-24-web-capability-seam.zh.md: d6051eec498edb640773ba367038581cebd1f654 +2026-06-24-web-capability-seam.md: 8c6c088ea5d7345f9955892b2d6054cfae518bbd +2026-06-24-web-capability-seam.zh.md: 4921c4a4647d180dbb00e6493a19d38584f99bdc diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md index 7e7b09f198..8c6c088ea5 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.md @@ -199,7 +199,7 @@ Full page retrieval remains the job of `web_fetch(url)`. Search snippets are dis ## Fetch request and result schema -The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, applies the basic transport hygiene below (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking), decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. (Full SSRF / private-network blocking is deferred — see [Deferred work](#deferred-work).) +The `web_fetch` implementation is an anonymous public HTTP(S) fetch provider, `http`. It fetches bytes from a concrete URL, resolves and pins public destinations, applies the transport hygiene below, decodes textual content, and returns only the minimal model-useful result: final URL, status code, body, and truncation. It carries no browser cookies, editor credentials, git credentials, internal auth tokens, or implicit access to private services. The seam request stays smaller than OpenCode's model-facing tool: @@ -235,12 +235,14 @@ The provider owns safe resource retrieval: URL validation, HTTP transport, redir The fetch provider's resource controls: - Only `http:` and `https:` URLs are accepted; credentials in URLs are rejected. +- A literal address or the complete result of one hostname lookup must contain only globally reachable unicast IPv4 or IPv6 destinations. IPv6 resolution also discovers the active DNS64 prefix and rejects NAT64 addresses that translate to non-public IPv4. Loopback, private, link-local, carrier-grade NAT, multicast, reserved, transition, translation, and private IPv4-mapped IPv6 addresses are rejected. +- The request retains that validated address set in an Undici lookup callback instead of resolving the hostname again. The original hostname remains the HTTP Host and TLS SNI value, while DNS rebinding cannot replace the connection destination after validation. - Maximum URL length, response byte cap, decoded body character cap, timeout, and redirect hop cap are enforced. - Abort signals propagate through network fetches and expensive decoding. -- Only same-origin redirects are followed automatically; a cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and therefore a fresh provider/permission decision. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.) +- Only same-origin redirects are followed automatically; each followed hop performs a fresh public-address lookup and pins its own connection. A cross-origin redirect fails with `WEB_REDIRECT_BLOCKED`, requiring a fresh tool call and fresh public-address validation. (Claude Code's WebFetch uses this same model — it does not auto-follow a cross-host redirect; it returns the redirect target to the model for a fresh call.) - Requests carry an explicit product user agent rather than silently impersonating a browser. -SSRF / private-network protection (blocking private, loopback, link-local, multicast, and otherwise non-public destinations, with DNS-resolve-then-validate to defeat rebinding and per-hop re-validation on redirects) is **deferred** — see [Deferred work](#deferred-work). Until it lands, `web_fetch` is an SSRF primitive and must not be enabled in a deployment that can reach sensitive internal network targets. +The provider rejects an entire DNS answer set when any address is not public instead of silently filtering the unsafe members. This fail-closed rule prevents connection-family selection or fallback from reaching an address that did not satisfy the public-network policy. ## Tool consumer behavior @@ -252,7 +254,7 @@ Tool registration is a minimal stable sync: on plugin startup the `dsh-tool-web` Provider availability changes affect execution results and diagnostics, not whether the model-facing schema exists. If a product wants no web tools at all, it disables `dsh-tool-web` or the individual web tool in config; if it wants web tools but the backend is misconfigured, the model sees a structured tool error at execution time. -The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links. +The prompt guidance explains the semantic split — `web_search` for discovery and current information, `web_fetch` when the model needs the content of a specific URL — and the prompt and tool result tell the model to cite relevant URLs with markdown links. Every successful result labels provider-controlled text as external untrusted data. Fetch conversion removes active and hidden HTML content; unsafe conversion returns a fixed omission marker rather than raw HTML. The model-facing output is text-first because tool results are `ContentBlock[]`, but the seam outcome stays structured so UI presentation and future adapters do not have to scrape rendered text. @@ -308,6 +310,18 @@ Rejected for the first version. Those providers often return extracted or summar Rejected for the seam. `prompt` turns fetch into LLM summarization and couples public-web retrieval to a model provider. The harness seam should fetch and decode deterministically; `dsh-tool-web` can later offer summaries as a presentation mode without making `ctx.web` depend on `ctx.llm`. +### Validate DNS and then call an ordinary fetch + +Rejected because an ordinary fetch resolves the hostname again when it opens the connection. An attacker can return a public address during validation and a private address during the second lookup. Passing the validated answer set through the connection's lookup callback closes that rebinding interval while preserving hostname-based HTTP and TLS behavior. + +### Block private-looking hostname strings without pinning resolved addresses + +Rejected because hostname syntax does not establish the connection destination: an arbitrary public-looking name can resolve to loopback, a private range, or a cloud metadata address. Address classification belongs after resolution, and every address available to connection fallback must pass it. + +### Require per-call approval before public fetches + +Rejected for the shipped presets. Public-address validation blocks SSRF destinations, while per-call confirmation would interrupt ordinary browsing without controlling public data egress reliably: a model can reach the same public network through mounted shell tools. Deployments that require a dedicated confirmation step can add a `tools/pre-execute` policy or disable `web_fetch`. + ## Consequences **The search schema is deliberately thin.** Exa and Perplexity both expose useful provider-specific controls; a control is added only once it can be defined provider-neutrally and enforced honestly by both tool registration and provider execution. @@ -318,19 +332,16 @@ Rejected for the seam. `prompt` turns fetch into LLM summarization and couples p **Provider state can change after startup.** A tool can be visible in the request assembled at step start and lose its provider before execution. The execution path resolves again and fails with a structured error. -**Fetch is a network boundary, not just a read-only tool.** `web_fetch` can reach sensitive network targets or exfiltrate data through URLs. Only the basic transport hygiene ships (http/https-only, credential rejection, byte/time caps, cross-origin redirect blocking); SSRF / private-network blocking is deferred (see [Deferred work](#deferred-work)), so until it lands `web_fetch` must not be enabled where it can reach internal targets. +**Fetch is a network boundary, not just a read-only tool.** Public-address validation and connection pinning prevent `web_fetch` from reaching non-public destinations, but a model can still disclose data through a public URL and fetched text remains untrusted model input. The shipped `cordis`, `code`, and `standard` presets expose `web_fetch` in every sandbox and approval mode without per-call confirmation. **Large web content can damage context quality.** Providers enforce byte/character caps and report `truncated`; `tool-web` formats bounded model output with clear continuation or follow-up guidance. ## Deferred work -- SSRF / private-network protection for `web_fetch`: block private, loopback, link-local, multicast, and otherwise non-public destinations so `web_fetch` is not an SSRF primitive. Doing it correctly is more than a URL-string check — it needs DNS-resolve-then-connect-to-the-validated-IP (to defeat DNS rebinding / TOCTOU), per-hop re-validation across redirects, and IPv6 edge handling (private ranges, IPv4-mapped addresses). Neither reference implementation surveyed does IP-level blocking (OpenCode does a prefix check then fetches; Claude Code relies on a centralized hostname blocklist plus a "private URLs will fail" prompt), so there is no implementation to copy and this is the harness's only SSRF defense — it warrants its own focused design/spike. Until it lands, `web_fetch` must only be enabled in deployments that cannot reach sensitive internal targets. - A `pdf` `WebFetchBody` kind: the `http` provider decodes text-extractable PDFs (best-effort, capped, `truncated`) into a `{ kind: 'pdf'; content; pageCount? }` arm, and `tool-web` renders it. This is fetch, not `web_extract` — PDF retrieval is a concrete HTTP 200 plus deterministic local decoding, not provider-side extraction of a non-HTTP resource. Adding it is a coordinated change across `dsh-web` (declare the arm), the provider (decode + narrow "binary rejection" to "reject binary except text-extractable PDF"; scanned/image PDFs needing OCR stay out of scope), and `tool-web` (render). The closed `WebFetchBody` union makes the consumer side fail to compile until the new arm is handled. - Provider-backed extraction as a separate `web_extract` capability, rather than widening `web_fetch` silently. -- Permission policy integration: the permission system now exists ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)) but bundles only sandbox mode and approval policy; web permission policy remains unintegrated. - Provider-neutral search controls beyond `query` and `maxResults`, once Exa and Perplexity can both honor them honestly. ## Open questions - Should product app packages probe web configuration at startup (treating `WEB_PROVIDER_CONFIGURED_MISSING`, `WEB_PROVIDER_CONFIGURED_UNAVAILABLE`, and `WEB_PROVIDER_AMBIGUOUS` as fatal when web is explicitly configured), or leave misconfiguration to surface at the first execution? -- Where should permission policy for public web access live in the shipped permission system ([sandbox and approval](../feature/2026-07-06-sandbox.md), [web permission presets](../feature/2026-07-23-web-permission-and-approval.md)): a dedicated web permission plugin on `tools/execute`, provider config, or both? diff --git a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md index d6051eec49..4921c4a464 100644 --- a/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-24-web-capability-seam.zh.md @@ -16,7 +16,7 @@ harness 需要面向模型的 web 工具,但不能将模型约定绑定到某 ## 决策 -Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.md): +Web 访问是一个一等能力 seam,遵循[能力 seam Agent Note](2026-06-13-capability-seams.zh.md): 1. `@deepseek-ai/dsh-web`(`packages/web/web`)拥有 `ctx.web`、提供方注册、提供方选择、共享的请求/结果词汇,以及 web 特有的错误。 2. 提供方包实现具体后端并向 `ctx.web` 注册能力,例如 `@deepseek-ai/dsh-web-search-exa`、`@deepseek-ai/dsh-web-search-perplexity`、`@deepseek-ai/dsh-web-search-deepseek` 和 `@deepseek-ai/dsh-web-fetch-http`。 @@ -199,7 +199,7 @@ Exa 搜索将提供方扁平 `results[]` 的每一项映射为 `WebSearchSource` ## Fetch 请求与结果 schema -`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,应用下述基本传输卫生措施(仅 http/https、拒绝 URL 中的凭证、字节/时间上限、跨源重定向阻断),解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。(完整的 SSRF/私有网络阻断推迟——见[推迟工作](#deferred-work)。) +`web_fetch` 的实现是一个匿名公开 HTTP(S) fetch 提供方 `http`。它从具体 URL 获取字节,解析并固定公开目的地址,应用下述传输卫生措施,解码文本内容,并仅返回最小的模型可用结果:最终 URL、状态码、正文和截断标志。它不携带浏览器 cookie、编辑器凭证、git 凭证、内部认证令牌,也不隐式访问私有服务。 seam 请求比 OpenCode 的面向模型工具更小: @@ -235,12 +235,14 @@ export type WebFetchBody = fetch 提供方的资源控制: - 仅接受 `http:` 和 `https:` URL;拒绝 URL 中的凭证。 +- 字面 IP 地址或 hostname 一次解析得到的完整结果只能包含全球可达的单播 IPv4 或 IPv6 目的地址。IPv6 解析还会发现当前 DNS64 前缀,并拒绝转换到非公开 IPv4 的 NAT64 地址。loopback、私有、link-local、运营商级 NAT、多播、保留、过渡、转换和映射到私有 IPv4 的 IPv6 地址都会被拒绝。 +- 请求通过 Undici lookup 回调保留这一组已验证地址,不会再次解析 hostname。原 hostname 仍作为 HTTP Host 与 TLS SNI 值,而 DNS rebinding 无法在验证后替换连接目的地址。 - 强制执行最大 URL 长度、响应字节上限、解码正文字符上限、超时和重定向跳数上限。 - Abort 信号传播到网络获取和高开销解码。 -- 仅自动跟随同源重定向;跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用,从而触发新的提供方/权限决策。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。) +- 仅自动跟随同源重定向;每个跟随的跳转都会重新解析公开地址,并把自己的连接固定到解析结果。跨源重定向以 `WEB_REDIRECT_BLOCKED` 失败,要求一次新的工具调用和新的公开地址校验。(Claude Code 的 WebFetch 使用同样的模型——它不自动跟随跨主机重定向,而是将重定向目标返回给模型以发起新调用。) - 请求携带显式的产品 User-Agent,而非静默伪装浏览器。 -SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他非公开目的地,通过先 DNS 解析再验证 IP 来防御 rebinding,并在重定向的每一跳重新验证)**推迟**——见[推迟工作](#deferred-work)。在其落地之前,`web_fetch` 是一个 SSRF 原语,不得在能触达敏感内部网络目标的部署中启用。 +只要 DNS 完整解析结果中存在任一非公开地址,提供方就会拒绝整个结果,而不是静默过滤不安全成员。该 fail-closed 规则可防止连接的地址族选择或回退触及未满足公开网络策略的地址。 ## 工具消费方行为 @@ -252,7 +254,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 提供方可用性变化影响执行结果和诊断信息,而非面向模型的 schema 是否存在。如果产品完全不需要 web 工具,在配置中禁用 `dsh-tool-web` 或单个 web 工具即可;如果需要 web 工具但后端配置有误,模型在执行时看到结构化的工具错误。 -提示词引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——提示词和工具结果告诉模型用 Markdown 链接引用相关 URL。 +提示词引导解释了语义分工——`web_search` 用于发现和获取当前信息,`web_fetch` 用于模型需要特定 URL 内容的场景——提示词和工具结果告诉模型用 Markdown 链接引用相关 URL。每个成功结果都会把提供方控制的文本标记为外部不可信数据。抓取转换会移除主动内容与隐藏 HTML 内容;无法安全转换时返回固定省略标记,而非原始 HTML。 面向模型的输出以文本为先,因为工具结果是 `ContentBlock[]`,但 seam 的产出保持结构化,以便 UI 展示和未来的适配器无需解析渲染后的文本。 @@ -280,7 +282,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 ## 测试 -每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 +每一层在自己的边界处固定:`dsh-web` 中的注册/选择/截断/abort 约定与 `WebError` 码;每个提供方基于录制的 fixture(测试前置数据)的请求/响应映射(Perplexity fixture 包含纯 URL 引用,以保持可选 source 字段的诚实性),加上每个真实提供方的自跳过带密钥冒烟测试;`web-fetch-http` 中的真实本地 HTTP 行为;`dsh-tool-web` 中通过真实工具注册表的启用驱动注册、结构化执行错误和结果格式化。一个真实 Loader 冒烟测试守护两种导出形状([事故复盘(postmortem) 0001](../../../../docs/postmortem/0001-acp-default-export-drops-inject.zh.md)):`dsh-web` 是默认导出的服务,而提供方和 `tool-web` 是命名空间插件,误加 `export default` 会丢失 `inject`。 ## 曾考虑的替代方案 @@ -308,6 +310,18 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 在 seam 层面否决。`prompt` 将 fetch 变成 LLM 摘要,并将公开 web 获取耦合到模型提供方。harness seam 应当确定性地获取和解码;`dsh-tool-web` 日后可以将摘要作为展示模式提供,而无需让 `ctx.web` 依赖 `ctx.llm`。 +### 验证 DNS 后调用普通 fetch + +否决,因为普通 fetch 在打开连接时会再次解析 hostname。攻击者可以在验证时返回公开地址,在第二次解析时返回私有地址。把已验证解析结果通过连接的 lookup 回调传入,可以在保留基于 hostname 的 HTTP 与 TLS 行为的同时关闭这一 rebinding 时间窗口。 + +### 只阻断看起来像私网的 hostname 字符串,不固定解析地址 + +否决,因为 hostname 语法无法确定连接目的地址:任意看似公开的名称都可能解析到 loopback、私有网段或云 metadata 地址。地址分类必须在解析后执行,连接回退可使用的每个地址都必须通过校验。 + +### 在公开抓取前要求逐次审批 + +已交付的 preset 不采用这一方案。公开地址校验会阻断 SSRF 目的地址,而逐次确认会打断普通浏览,却不能可靠控制公开数据出站:模型可以通过已挂载的 shell 工具访问同一公开网络。要求专门确认步骤的部署可以添加 `tools/pre-execute` 策略或禁用 `web_fetch`。 + ## 后果 **搜索 schema 刻意精简。** Exa 和 Perplexity 都暴露了有用的提供方特有控制;只有当某个控制能以提供方无关的方式定义、且工具注册和提供方执行都能诚实遵守时,才会添加。 @@ -318,7 +332,7 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 **提供方状态可能在启动后变化。** 一个工具可能在步骤开始时组装的请求中可见,但在执行前失去其提供方。执行路径重新解析并以结构化错误失败。 -**Fetch 是网络边界,不仅仅是只读工具。** `web_fetch` 能触达敏感网络目标或通过 URL 外泄数据。仅交付基本传输卫生措施(仅 http/https、拒绝凭证、字节/时间上限、跨源重定向阻断);SSRF/私有网络阻断推迟(见[推迟工作](#deferred-work)),因此在其落地之前,`web_fetch` 不得在能触达内部目标的环境中启用。 +**Fetch 是网络边界,不仅仅是只读工具。** 公开地址校验与连接固定可防止 `web_fetch` 触达非公开目的地址,但模型仍可通过公开 URL 泄露数据,抓取文本也仍是不受信任的模型输入。已交付的 `cordis`、`code` 与 `standard` preset 会在所有 sandbox 和审批模式下暴露 `web_fetch`,无需逐次确认。 **大量 web 内容可能损害上下文质量。** 提供方强制执行字节/字符上限并报告 `truncated`;`tool-web` 格式化有界的模型输出,附带清晰的继续或后续引导。 @@ -326,13 +340,10 @@ SSRF/私有网络防护(阻断私有、回环、链路本地、多播及其他 ## 推迟工作 -- `web_fetch` 的 SSRF/私有网络防护:阻断私有、回环、链路本地、多播及其他非公开目的地,使 `web_fetch` 不再是 SSRF 原语。正确实现不仅仅是 URL 字符串检查——需要先 DNS 解析再连接到已验证的 IP(防御 DNS rebinding/TOCTOU)、跨重定向的每跳重新验证,以及 IPv6 边缘处理(私有范围、IPv4 映射地址)。所调研的参考实现均未做 IP 级阻断(OpenCode 做前缀检查后直接 fetch;Claude Code 依赖集中式主机名黑名单加「私有 URL 会失败」的提示词),因此没有可复制的实现,且这是 harness 唯一的 SSRF 防线——值得一次专门的设计/spike。在其落地之前,`web_fetch` 只能在无法触达敏感内部目标的部署中启用。 - `pdf` `WebFetchBody` 类别:`http` 提供方将可文本提取的 PDF 解码(尽力而为、有上限、`truncated`)为 `{ kind: 'pdf'; content; pageCount? }` 分支,`tool-web` 渲染它。这是 fetch 而非 `web_extract`——PDF 获取是具体的 HTTP 200 加确定性的本地解码,不是提供方侧对非 HTTP 资源的提取。添加它是跨 `dsh-web`(声明分支)、提供方(解码 + 将「二进制拒绝」收窄为「拒绝二进制,但可文本提取的 PDF 除外」;需要 OCR 的扫描/图片 PDF 不在范围内)和 `tool-web`(渲染)的协调变更。封闭的 `WebFetchBody` 联合类型使消费方在新分支被处理之前编译失败。 - 提供方支撑的提取作为独立的 `web_extract` 能力,而非静默扩展 `web_fetch`。 -- 权限策略集成:权限系统现已存在([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md)),但只捆绑了沙箱模式与审批策略;web 权限策略仍未集成。 - `query` 和 `maxResults` 之外的提供方无关搜索控制,待 Exa 和 Perplexity 都能诚实遵守时再添加。 ## 开放问题 - 产品应用包是否应在启动时探测 web 配置(当 web 被显式配置时将 `WEB_PROVIDER_CONFIGURED_MISSING`、`WEB_PROVIDER_CONFIGURED_UNAVAILABLE` 和 `WEB_PROVIDER_AMBIGUOUS` 视为致命错误),还是将配置错误留到首次执行时浮出? -- 在已交付的权限系统([沙箱与审批](../feature/2026-07-06-sandbox.md)、[web 权限预设](../feature/2026-07-23-web-permission-and-approval.md))中,公开 web 访问的权限策略应放在哪里:`tools/execute` 上的专用 web 权限插件、提供方配置,还是两者兼有? diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml index c49cd144b9..421739cb57 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md 2026-06-26-file-context-as-event-gate.md: a8316741c3ce8548db5b5def431425589994995c -2026-06-26-file-context-as-event-gate.zh.md: 78b1b759edc0340fd7be103fc9bfec0b125e0800 +2026-06-26-file-context-as-event-gate.zh.md: 618985f33073cf9fd69cf2c7fda7112d9752468f diff --git a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md index 78b1b759ed..618985f330 100644 --- a/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 +[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 在面向模型的工具与 `ctx.fs` 提供方之间放置了 `ctx.fileContext`:`dsh-tool-fs` 注入 `fileContext`,并将每次 `read`/`write`/`edit` 路由到它的方法。这使得 `fileContext` **位于关键路径上且不可省略**。工具不经过它就无法访问 `ctx.fs`,策略层掌控着 fs I/O 和读取窗口,而一个不需要观测状态策略的部署也无法简单地移除该包——`dsh-tool-fs` 会因无法解析 `ctx.fileContext` 而失败。 这把三件本应可分离的事情耦合在了一起: @@ -33,7 +33,7 @@ provider dsh-fs-local local implementation of ctx.fs 该模型是叠加式的:裸 `ctx.fs` 执行原子化、无约束的文本 I/O,而 `dsh-fs-observation-policy` 叠加观测状态、先读后编辑和版本守卫。因此移除策略层后工具仍可用,只是不受约束。正式发布的 agent(智能体)配置会加载策略;裸模式的存在是为了让策略在服务边界保持可选,而非作为正常部署姿态。 -[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。 +[文件系统缺失观测后续决策](../bug-fix/2026-08-09-filesystem-absence-observation.zh.md)把记录载荷从仅表示成功的版本细化为显式的存在/缺失状态,并要求带防护的创建以不替换方式发布。事件门控归属与无 I/O 策略边界保持不变。 `dsh-tool-fs` 不再注入 `fileContext`。它注入 `fs` 和 `tools`/`systemPrompt`。 @@ -152,7 +152,7 @@ interface Events { ## 取代关系 -本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 +本 Agent Note 修正——而非推翻——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md)。四层拆分、提供方约定和新鲜度*策略*均保留。变更的是**工具与策略层之间的耦合方式**:强制性方法服务变为插件拥有的事件门控,fs I/O + 读取窗口从 `fileContext` 上移至 `dsh-tool-fs`。拆分文件系统 seam Agent Note 中关于 `dsh-tool-fs` 注入 `fileContext` 以及 `fileContext` 拥有 `read`/`write`/`edit` 的描述已在同一变更中更新。 ## 验证 @@ -160,7 +160,7 @@ interface Events { ## 曾考虑的替代方案 -- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 +- **保留 `ctx.fileContext` 作为关键路径上的方法服务**——[拆分文件系统 seam Agent Note](../simplification/2026-06-26-fsspec-style-fs-seam.zh.md) 最初落地的形态;否决,因为工具无法在没有策略层的情况下运行,使策略对基本操作是承重性的,而非可选的收紧。 - **策略侧版本检查**(`dsh-fs-observation-policy` 在其 waterfall 处理器中 stat 并比较版本)——否决,因为该检查与工具实际写入之间存在 TOCTOU 间隙;提供方的 mutation 临界区是唯一无竞态的位置,因此策略只选择 CAS 基准并对先前观测进行门控。 - **每工具 `/read`/`/write`/`/edit` 子路径插件**——实现时放弃:没有消费方需要单工具部署,且子路径发布迫使引入兄弟工具包都不需要的定制 `tsdown`/`tsconfig`/`files`/workspace-constraint 处理;每工具的注册辅助函数仍作为根插件组合的内部模块保留。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml index 7a5d35f882..04eec8e0e1 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.md 2026-06-30-bash-stdin-env-trusted-plugin-api.md: 41be63fff598587ee9b873cf7edf51da788bc02a -2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 4f0338cbb6a265ff629a81e9c9a12c4456941ba0 +2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md: 60721539d9d37857e145e289bb262482588139bf diff --git a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md index 4f0338cbb6..60721539d9 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-bash-stdin-env-trusted-plugin-api.zh.md @@ -8,7 +8,7 @@ Status: implemented 钩子子系统以 Claude Code 和 Codex 的方式运行外部钩子命令:钩子是一条 shell 命令,通过 **stdin 上的 JSON** 接收事件载荷,并从若干**环境变量**(`CLAUDE_PROJECT_DIR`、`CLAUDE_PLUGIN_ROOT`、`PLUGIN_ROOT`……)读取上下文。harness 已经在 `ctx.shell` 能力 seam 后面有一个完善的命令执行器([dsh-shell](../../../../packages/shell/shell) → [dsh-bash-local](../../../../packages/shell/bash-local)),具备进程组终止、输出截断/spill 处理和凭证擦除功能。复用它来执行钩子意味着钩子桥接层无需重新实现子进程底层机制——但该 seam 此前无法写入 stdin 或设置额外 env。本次变更添加这两个输入。 -`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.md)。 +`stdin` 和 `env` 不构成新的模型能力,因为普通 shell 语法已经能提供两者。环境凭证由 `dsh-bash-local` 的子环境擦除机制保护,而非靠隐藏这些 Service Definition 字段;模型工具参数是静态 JSON,不会展开 shell 变量。因此这些字段服务于受信的进程内调用方(如钩子桥接层),它们需要传递结构化输入和 `CLAUDE_*` 变量,而不必将其嵌入模型可见的 shell 文本。环境变量规则见 [defensive-patterns.md](../../../../docs/defensive-patterns.zh.md)。 ## 决策 @@ -16,7 +16,7 @@ Status: implemented 三个有意为之的选择: -1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 +1. **模型侧工具不暴露 `stdin` 和 `env`。** Shell 语法已覆盖这些需求,重复参数只会增加接口面而不带来权限隔离。工具仅从声明的模型参数、signal 和 owner 构建请求;受信的进程内调用方可以直接设置请求字段。harness 自有变量使用[托管环境决策](../feature/2026-07-10-agent-session-identity-and-log-location.zh.md)规定的独立 `dshEnv` 通道,因此普通 `env` 无法替换它们。 2. **`env` 在凭证擦除之后合并,因此调用方显式设置的条目即使具有凭证形态的名称也会胜出。** 后续的托管命名空间决策负责管理 `DSH_*`:这类环境条目会被移除,受信的 `dshEnv` 最后合并,因此普通 `env` 条目永远无法顶掉托管值。完整顺序为 `scrub(process.env, including DSH_*)` → `ENV_OVERRIDES` → 普通 `env` → `dshEnv`。 @@ -30,4 +30,4 @@ Status: implemented ## 后果 -钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.md)。 +钩子桥接层通过既有的 bash seam 传递 JSON 载荷和钩子特定变量,保留其进程组终止、截断和 spill 行为。面向模型的行为不变,bash 工具仍是模型调用请求构建的唯一所有者。相关词汇定义见 [bash 数据结构参考](../../../../docs/subsystems/shell.zh.md)。 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml index 171f33fe17..feb512101f 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.md 2026-06-30-event-domain-semantics.md: 70da718b5471ce309a090c8aade3e7290cc949dc -2026-06-30-event-domain-semantics.zh.md: 6795e1f6f583d6fe7d34efc5e1619da38ab1c89f +2026-06-30-event-domain-semantics.zh.md: c3b12a167da0a41b792914d82a675a98b3a0b860 diff --git a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md index 6795e1f6f5..c3b12a167d 100644 --- a/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-30-event-domain-semantics.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: +harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环)(见[微内核事件分类体系 Agent Note](2026-06-11-microkernel-event-taxonomy.zh.md))。随着该分类体系的增长,三个事件域之间的界限变得模糊: - `session/*` 承载持久的、事件溯源的日志(`SessionEventMap`)。 - `agent/*` 承载运行时实时信号,向插件传递 `Agent` 句柄。 @@ -26,14 +26,14 @@ harness 通过 Cordis 事件分类体系扩展 agent loop(智能体循环) **边界规则:** 持久的、可回放的事实是 `SessionEvent`;实时拦截或瞬态/活对象信号是 `agent`/`tools` Cordis 事件。轮次或步骤边界是持久事实,因此存在于会话日志中并从 `session/event` 源读取——不会被镜像为 `agent/*` emit。 -**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 +**将规则应用于边界镜像:** 全部四个边界镜像——`agent/turn-start`、`agent/turn-end`、`agent/step-start`、`agent/step-end`——被**移除**。没有生产消费方需要在边界处获取活的 `Agent`:ACP(Agent Client Protocol)桥接将其进行中的提示词与精确对应的 `session/event` `turn/start`/`turn/end` 事件对关联,其他 transcript 消费方同样从持久流派生边界。见[移除边界镜像事件 Agent Note](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md),该决策由它负责。移除 emit 也简化了循环的 `closeStep`/`closeTurn`(各只需一次 append,无需配对 emit)。 ## 后果 - 循环不再 emit 任何边界镜像;`closeStep` 仅追加 `step/end`,`closeTurn` 仅追加 `turn/end`。`Session.append` 负责 post-commit observer 隔离,因此抛出异常的边界 observer 无法改变轮次结果或饿死后续消费方;事件接纳失败或内部校验失败仍会在边界进入日志之前向外抛出。 - 之前通过已移除 emit 观察边界的测试,现在观察持久的 `turn/start`/`turn/end`/`step/start`/`step/end` 会话事件——它们所锁定的行为(边界顺序、步骤计数)不变;只是读取的源移到了规范源。那些测试*抛出异常的轮次边界 emit 监听器*的用例被删除,因为该代码路径不再存在(没有 emit 可供抛出)。按照 [AGENTS.md「测试记录行为,而非黄金真相」](../../../../AGENTS.md),行为与其测试一同迁移(或一同消亡)。 - 循环仅在 `append('step/start')` 返回后才标记步骤已打开(`stepOpen = true`)。内部分发校验在日志推入之前运行,可能在不打开步骤的情况下拒绝;post-commit `session/event` observer 的失败被隔离在 `Session.append` 内部。因此该标记精确表示已提交的、欠一个后续 `step/end` 的边界。 -- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 +- 完整实现见[简化 Agent Note「停止将持久边界镜像为 agent 事件」](../simplification/2026-06-20-remove-agent-boundary-mirror-events.zh.md):全部四个边界镜像被移除,所有消费方从 `session/event` 读取边界。`agent/steering`(不是边界镜像)不在该 Agent Note 范围内,由其后续 Agent Note [移除 `agent/steering` 镜像 emit](../../archived/simplification/2026-07-04-remove-agent-steering-mirror.md) 单独移除——它镜像的是持久的中途 steering `user/message`。 - 生成的 Cordis 事件表面(`docs/subsystems/` 各页)不再列出镜像事件。 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 fa8401fdc8..933b3d2826 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # 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: 52626d3d9200146df95aee7836d37bbaf7e6a9ec -2026-07-02-tool-render-intent-union.zh.md: 43fda366694aecda07b276d34c882e75d6b4aa95 +2026-07-02-tool-render-intent-union.zh.md: bc91b89c53c3c43b6c483dc0d4876a00f5a936b7 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 43fda36669..bc91b89c53 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 @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-02-tool-render-intent-union.md) | 中文 -> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。 +> render-intent 联合类型对 UI 传输层仍然有效;其 ACP(Agent Client Protocol)映射已被 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)取代。 ## 问题 @@ -78,5 +78,5 @@ terminal 意图只用于展示。harness 仍通过自身的 bash 服务执行命 ## 相关 - 取代早先被否决的折叠工具自有呈现提案(已否决——「等两个真实工具和两个真实消费方,然后做带标签 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)拥有。 +- 被[结果时已应用 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.zh.md)拥有。 - 将 `ToolTerminal` 折入当前 UI 传输层使用的带标签 `terminal` 视图。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml index 800449486f..fcdffd69cd 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md -2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 9a53619d9510e3f4fa561f8420b2da3bedbbf4bb -2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6174f277f0a4e8c46ff8d732411f28b4ea97728c +2026-07-05-prompt-variables-and-tool-guidance-ownership.md: 361184fa7dbdaccd49ac19235c016daf5eb5ca53 +2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md: 6b462572883fb69ca64f2babf28974ae59e7bd74 diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md index 9a53619d95..361184fa7d 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md @@ -32,7 +32,7 @@ Plugins register `{{name}}` values through `ctx.systemPrompt.variable(name, prov ### Persona as the order-0 section -`dsh-system-prompt` owns `harness:identity` at order `-100` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The conventional order bands are identity `-100`, persona `0`, and tool guidance `100–199`. +`dsh-system-prompt` owns `harness:identity` at first-party order `-1000` and the configured `deployment:persona` at order 0, so both survive a replacement loop. Prompt rendering has one path, `renderPrompt(assembly)`, and the routed request header therefore records the exact prompt later replayed by `ctx.tokenMeter` for compaction pressure. An agent-scoped `deployment:persona` shadows the global default and lets subagent providers install a persona before publication. The [first-party order allocation](2026-08-25-sparse-first-party-prompt-section-orders.md) owns the sparse named placements for identity, policy, tool guidance, generated protocol, and final-output obligations. ### Tool guidance ownership diff --git a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md index 6174f277f0..6b46257288 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md @@ -32,7 +32,7 @@ Status: implemented ### Persona 作为 order-0 section -`dsh-system-prompt` 拥有 order 为 `-100` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩(compaction)压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。约定的 order 区间为:identity `-100`、persona `0`、工具指导 `100–199`。 +`dsh-system-prompt` 拥有 first-party order 为 `-1000` 的 `harness:identity` 和 order 为 0 的配置 `deployment:persona`,因此两者在循环被替换时仍然存活。提示词渲染只有一条路径 `renderPrompt(assembly)`,已路由请求 header 因此会记录准确的提示词,稍后由 `ctx.tokenMeter` 为压缩(compaction)压力回放。agent 作用域的 `deployment:persona` 遮蔽全局默认值,允许 subagent 提供方在发布前安装 persona。[first-party 顺序分配](2026-08-25-sparse-first-party-prompt-section-orders.zh.md)规定身份、策略、工具指导、生成协议和最终输出义务的稀疏具名位置。 ### 工具指导归属 @@ -40,7 +40,7 @@ Status: implemented ### Subagent 对话历史描述符 -`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)。 +`SubagentProvider.inheritsParentContext` 描述的是对话历史初始化,而非作用域、服务、工具或权限。spawn 和 ACP 将其设为 `false`;fork 设为 `true`。`dsh-tool-subagent` 根据该标志派生工具描述和提示词参数描述,包括 fork 继承已完成轮次但不继承进行中轮次这一点。提供方生命周期事件使该措辞与响应式提供方注册保持同步;其设计动机见[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)。 ## 曾考虑的替代方案 @@ -49,7 +49,7 @@ Status: implemented - **在每个 persona 中手写模型名称**:与上方一行的 `model:` 键重复,配置修改后静默失实;正是本决策要治愈的病症。 - **宽松插值(未知引用保留原样或替换为空)**:一个拼写错误 `{{modle}}`(或一个空洞)会被发送给模型,直到 transcript(文本记录)审查时才会被发现。 - **在配置中为每个 subagent 实例编写措辞**:面向模型的行文回到每个部署 × 实例中,重蹈在 leaf YAML 中手写指导的漂移。**根据提供方名称选择措辞**:`providerName` 本身是配置,重命名提供方后会静默获得错误的措辞。 -- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.md)中被否决。 +- **在 `apply` 时解析提供方(加载顺序要求)**与**仅用 section 承载 subagent 措辞(在 assemble 时惰性解析)**:提供方生命周期事件的替代方案;两者均在[提供方生命周期事件 Agent Note](2026-07-05-subagent-provider-lifecycle-events.zh.md)中被否决。 ## 不在范围内 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml index b2478911dc..a9d00bc3b9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md -2026-07-05-reconstructable-requests.md: 63146fa2d392a45543daa32ce2b00158782fddb2 -2026-07-05-reconstructable-requests.zh.md: 94c1d323be0107eb8b6072a05d1e8832ebd1fffc +2026-07-05-reconstructable-requests.md: 3786de02d06c0b6c094297ae89ac3f84053e408d +2026-07-05-reconstructable-requests.zh.md: 851045aca7dababd0da859f3b04b721c65382fc3 diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md index 63146fa2d3..3786de02d0 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md @@ -22,9 +22,9 @@ Prefix-cache stability is corollary #1, not the headline: an append-only log pro **Messages.** `Session.deriveMessages()` is cached: each surface entry is projected exactly once, when first seen, through the public per-event function `deriveEventMessage(event)`; a surface rewrite (a compaction `replace` — `SurfaceManager.replaceGeneration`) rebuilds. Callers get a fresh array per call over shared, deep-frozen messages: mutating logged history through a projection is unrepresentable (it throws), replacing the old clone-per-call isolation. External reconstructors fold the same public function over a log prefix, so no two paths can disagree. -`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, and an in-instance change uses `change`. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. +`EpochHeader` records the request's non-history state: call config, rendered system prompt, and tool schemas, with empty values canonicalized to absence. Adapter-supplied effort and token defaults retain their `adapterDefaults` provenance; a Web model selection restored from the log omits an adapter-owned effort so the next resolution cannot reclassify the same effective config as an explicit selection and a false change. `request/header` always writes a full snapshot: the first loop instance uses reason `initial`, later instances use `resume`, an in-instance change uses `change`, and an unchanged envelope beginning an explicitly declared message series or following a surface replacement uses `series`. A `change` snapshot carries `startsSeries: true` when the changed request also starts a series, preserving the two independent facts without a duplicate header. Ordinary append-only later Turns, further same-series Steps, and retries inherit the latest snapshot. `foldRequestHeader` selects the latest snapshot. Legacy `request/header-delta` events and the removed `fallback` reason are rejected when appended or loaded. -Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start` and records the final message batch as `user/message` events. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed full header snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. +Each proposed step first claims its inbox batch and runs `agent/pre-step`. Rejection opens no step; enter opens `step/start`, records the final message batch as `user/message` events, and may use `startsRequestSeries: true` to declare a distinct series. The step then assembles the system prompt and tools, while `agent/request` may replace only the frozen call-config seed. The loop records the owed initial, resume, change, or series full snapshot, builds `GenerateOptions` from derived messages and that header, and deep-freezes it while leaving `AbortSignal` live. The first call config starts from explicit `AgentOptions`, preserving fork overrides and resume reconfiguration; later calls start from the folded header. **The open step is the reconstruction boundary.** Its entered `user/message` batch and any newly written `request/header` precede request dispatch. Injection after the atomic claim joins a later request, while a listener that must affect this request returns messages through `agent/pre-step`. Header reconstruction selects the step's `request/header`, or carries the prior snapshot when no new header is written. @@ -42,6 +42,7 @@ Like MiniCode, the conversation advances append-only and resets only when model- - **Detect-and-report** (compare consecutive requests, warn on divergence): catches violations after the fact; a violating request is still constructible and ships. Rejected for interface-level unrepresentability. - **Event-driven assembly** (re-render only on change signals): a missed-signal bug class — a tool registered mid-session emits `tools/change`, not `system-prompt/change`, and a third-party provider may emit nothing. Per-step render + value compare is robust with zero signal discipline. - **A custom header-delta codec** (system line edits, name-keyed tool edits, whole config/prefix replacements): reduced repeated bytes but duplicated the representation and its diff/apply/fallback machinery. Full snapshots retain one replay representation. +- **A lightweight series marker referencing the previous header**: reduced repeated prompt and tool bytes, but a window beginning at that marker could not render or reconstruct the request without fetching its predecessor. A self-contained full snapshot preserves one representation for persistence, partial history, and snapshot pinning. - **Narrative changed-field lists on header snapshots**: derivable by comparing consecutive snapshots. The `reason` remains because an instance boundary is not derivable from the snapshot values. ## Consequences @@ -51,5 +52,6 @@ Like MiniCode, the conversation advances append-only and resets only when model- - What still costs full price at the provider is inherent and logged: compaction (its `compaction/*` events and replacement entry), a real prompt, tool, or config change (`request/header` with reason `change`), or a process boundary with drift (a differing `resume` snapshot). The provider's own reasoning-content exclusion is managed server-side. - `agent/pre-step` is the current-request message channel; direct inbox mutation is the eventual later-request channel. - Tool-result trimming needs no new mechanism: a logged single-entry surface replace (`start === end`) carrying a trimmed `tool/result` under the same `callId` — compaction-family, replay-correct, cache-bust batched by the same pressure logic. -- Session logs grow one `request/header` snapshot per loop instance plus snapshots on real changes. This is larger than a delta codec but small beside chunk-heavy logs and retains one replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. -- Snapshot expected outputs changed once (every transcript gains its header events); the fs-writing fixtures are stored in the normalized authored form with cwd-relative tool arguments, because replay only round-trips cwd-independent argument paths. +- Unreadable referenced attachment objects still fail model requests; [automatic attachment quarantine](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.md) records the proposed recovery without weakening byte-exact reconstruction. +- Session logs grow one `request/header` snapshot per loop instance, real change, and later model-message series. Repeating the full system prompt and tool catalog is larger than a delta codec but small beside chunk-heavy logs and retains one self-contained replay representation. `SESSION_FORMAT_VERSION` stays `0`; legacy delta events are rejected rather than migrated. +- Snapshot fixtures include each repeated series header. Keyless refresh owns those deterministic log changes, while the snapshot harness pins prompt and tool sidecars only for the initial and actual change revisions and reuses the current revision for `series` snapshots. Filesystem-writing fixtures remain in normalized authored form with cwd-relative tool arguments because replay only round-trips cwd-independent argument paths. diff --git a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md index 94c1d323be..851045aca7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md @@ -22,9 +22,9 @@ Status: implemented **消息。** `Session.deriveMessages()` 带缓存:每个 surface 条目在首次出现时通过公开的逐事件函数 `deriveEventMessage(event)` 精确投影一次;surface 重写(压缩的 `replace`,即 `SurfaceManager.replaceGeneration`)触发重建。调用方每次获得一个新数组,底层是共享的深度冻结消息:通过投影变异已记录的历史是不可表达的(会抛异常),取代了旧的逐次调用克隆隔离。外部重建器对日志前缀折叠同一个公开函数,因此不可能有两条路径产生分歧。 -`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 +`EpochHeader` 记录请求的非历史状态:调用配置、渲染后的系统提示词和工具 schema,空值规范化为缺失。适配器提供的推理强度与 token 默认值会保留其 `adapterDefaults` 来源信息;Web 从日志恢复模型选择时会省略适配器持有的推理强度,因此下一次解析不会把相同的有效配置重新归类为显式选择并产生虚假变更。`request/header` 始终写入完整快照:首个循环实例使用 reason `initial`,后续实例使用 `resume`,实例内变更使用 `change`,内容未变的封装显式开启消息序列或跟随表层替换时使用 `series`。如果发生变化的请求同时开启序列,`change` 快照会携带 `startsSeries: true`,无需重复 header 即可保留这两个独立事实。普通的仅追加后续 Turn、同一序列内后续的 Step 与重试沿用最新快照。`foldRequestHeader` 选择最新快照。旧的 `request/header-delta` 事件和已移除的 `fallback` reason 在追加或加载时都会被拒绝。 -每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,并把最终消息批次记录为 `user/message` 事件。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的完整 header 快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 +每个拟议步骤先领取其 inbox 批次,再运行 `agent/pre-step`。reject 不打开步骤;enter 打开 `step/start`,把最终消息批次记录为 `user/message` 事件,并可使用 `startsRequestSeries: true` 声明独立序列。随后步骤组装系统提示词与工具,`agent/request` 只能替换冻结的调用配置种子。循环记录所需的 initial、resume、change 或 series 完整快照,从派生消息与该 header 构建 `GenerateOptions`,对其深度冻结但保持 `AbortSignal` 活跃。首次调用配置从显式的 `AgentOptions` 出发,保留 fork 覆盖和恢复重配置;后续调用从折叠后的 header 出发。 **已打开步骤是重建边界。** 进入步骤的 `user/message` 批次与任何新写入的 `request/header` 都位于请求分派之前。原子领取后发生的注入加入后续请求;必须影响本次请求的监听器则通过 `agent/pre-step` 返回消息。header 重建选择该步骤的 `request/header`,或在无新 header 写入时沿用前一个快照。 @@ -42,6 +42,7 @@ Status: implemented - **检测并报告**(比较连续请求,发散时告警):事后捕获违规;违规请求仍可构造并发出。因违规必须在接口层面不可表达而否决。 - **事件驱动组装**(仅在变更信号时重新渲染):存在漏信号的 bug 类别——会话中途注册的工具发出 `tools/change` 而非 `system-prompt/change`,第三方提供方可能什么都不发。逐步骤渲染加值比较在零信号纪律下即可稳健工作。 - **自定义 header-delta 编解码器**(系统行编辑、按名称键控的工具编辑、完整配置/前缀替换):减少了重复字节,却复制了表示及其 diff/apply/fallback 机制。完整快照只保留一种回放表示。 +- **引用前一个 header 的轻量 series 标记**:减少重复的提示词与工具字节,但从该标记开始的窗口若不再读取前序,就无法渲染或重建请求。自包含的完整快照让持久化、局部历史和快照固定共用一种表示。 - **Header 快照上的叙事性变更字段列表**:可以通过比较连续快照推导。`reason` 仍保留,因为实例边界无法从快照值推导。 ## 后果 @@ -51,5 +52,6 @@ Status: implemented - 在提供方处仍需全价计算的内容是固有的且已记录的:压缩(其 `compaction/*` 事件和替换条目)、真正的提示词、工具或配置变更(reason 为 `change` 的 `request/header`),或带漂移的进程边界(不同的 `resume` 快照)。提供方自身的 reasoning-content 排除由服务端管理。 - `agent/pre-step` 是当前请求的消息通道;直接修改 inbox 则是最终进入后续请求的通道。 - 工具结果裁剪无需新机制:一个已记录的单条目 surface replace(`start === end`),携带同一 `callId` 下裁剪后的 `tool/result`——属压缩家族,回放正确,缓存失效由相同的压力逻辑批量处理。 -- 会话日志每个循环实例增长一个 `request/header` 快照,并在真正变更时增加快照。它比 delta 编解码器更大,但相对分片密集型日志仍然很小,并只保留一种回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 -- 快照预期输出变更一次(每个 transcript(文本记录)增加其 header 事件);写入文件系统的 fixture(测试前置数据)以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 +- 无法读取的被引用附件对象仍会让模型请求失败;[附件自动隔离](../../proposed/bug-fix/2026-08-20-attachment-read-quarantine.zh.md)记录了不削弱字节精确重建的拟议恢复方案。 +- 会话日志会为每个循环实例、真实变更和后续模型消息序列增加一个 `request/header` 快照。重复完整系统提示词与工具目录比 delta 编解码器更大,但相对分片密集型日志仍然很小,并保留一种自包含的回放表示。`SESSION_FORMAT_VERSION` 保持 `0`;旧的 delta 事件被拒绝而非迁移。 +- 快照 fixture 包含每个重复的 series header。无密钥 refresh 负责这些确定性日志变化;快照 harness 只为 initial 与真实 change 修订固定提示词和工具 sidecar,并让 `series` 快照复用当前修订。写入文件系统的 fixture 继续以规范化的撰写形式存储,工具参数使用 cwd 相对路径,因为回放只对 cwd 无关的参数路径做往返。 \ No newline at end of file diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml index 336d9669ed..a66c6f6065 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.md 2026-07-05-subagent-provider-lifecycle-events.md: 503c0d638785e3a4944c8903b8d7d469c68b1881 -2026-07-05-subagent-provider-lifecycle-events.zh.md: e7d1d8c398a6d77efb2dbccc365e0040f38f2d33 +2026-07-05-subagent-provider-lifecycle-events.zh.md: d9d15b4ff688506b84b81c8649e1de4ce50e086d diff --git a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md index e7d1d8c398..d9d15b4ff6 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-subagent-provider-lifecycle-events.zh.md @@ -6,9 +6,9 @@ Status: implemented ## 问题 -[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 +[提示词变量 Agent Note](2026-07-05-prompt-variables-and-tool-guidance-ownership.zh.md) 让 `dsh-tool-subagent` 从其提供方派生面向模型的措辞:`SubagentProvider.inheritsParentContext`(spawn 和 ACP(Agent Client Protocol)为 `false`,fork 为 `true`)同时驱动工具描述和 `prompt` 参数描述,使 fork 工具不再在上下文继承问题上对模型撒谎。这一修复引入了跨 fiber 的数据依赖:工具描述在工具注册时固定(这是有意为之——描述是 tool-choice 引导所在之处),但提供方在自己的插件 fiber 上到达,时机不确定。 -如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.md))。 +如果在工具插件的 `apply` 时刻解析提供方,就会产生一个隐式的加载顺序要求(「在 cordis.yml 中把后端列在工具前面」)。这个要求不成立,因为 Cordis Loader 并发启动同级条目,且 `Entry.init()` 不会等待激活完成:延迟到达的后端即使列在前面,也可能让工具 fiber 失败。Loader 不提供同级顺序保证——「异步状态不是同步状态」(见[防御性模式](../../../../docs/defensive-patterns.zh.md))。 ## 决策 @@ -31,6 +31,6 @@ Status: implemented ## 后果 - 从命名提供方派生状态的消费方响应 `subagent/provider-added`/`-removed` 事件,而非在 `apply` 时读取注册表;`dsh-tool-subagent` 是参考实现。 -- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.md)。 +- **添加时大声失败;移除时按监听器隔离。** 添加监听器可以回滚注册。移除在 disposal 期间运行,因此单个监听器抛异常只会被记录日志,不会饿死后续镜像或干扰拆解流程。`start()` 仍在每次运行时按名称解析提供方,防止陈旧工具调用已移除的后端。见[事件目录](../../../../docs/subsystems/subagent.zh.md#cordis-surface)与[生产者/消费方映射](../../../../docs/event-producer-consumer.zh.md)。 - **工具不存在的窗口期。** 在后端 disposal 与重新注册之间(HMR 重载期间),模型看不到 subagent 工具。这是诚实的状态——替代方案是一个向空处分发的工具——工具注册表发出的 `tools/change` 事件会使提示词组装保持最新状态。 - **两个等待中的 fiber 共享同一 `toolName` 是无效配置,被延迟捕获。** 如果两个 `dsh-tool-subagent` 加载实例分别指定了不同的提供方但相同的 `toolName`,两者都会等待,先到达的提供方先注册;第二次注册仅在其提供方到达时才抛异常。插件中的 `TODO(subagent-dup-toolname)` 记录了这一影响范围;工具注册表的重名拒绝机制仍是最终防线。 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml index cf8eaf225c..cc278292d9 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.md 2026-07-05-windows-jsonl-durable-publish.md: 546cde6086f3c84e22b4d4de144a48bb3425cd4e -2026-07-05-windows-jsonl-durable-publish.zh.md: 205460bcd374bd351cfdf541eb4041c09461229d +2026-07-05-windows-jsonl-durable-publish.zh.md: b99032e9d7e72e2028f39c5af91659278fd81343 diff --git a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md index 205460bcd3..b99032e9d7 100644 --- a/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-05-windows-jsonl-durable-publish.zh.md @@ -28,7 +28,7 @@ Windows 通过持久的暂存发布来创建缺失目录:创建一个以固定 ## 影响 -该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.md)会先作用于不透明字节,然后才由任一平台执行发布。 +该后端在各平台上维持同一项外部约定:首次追加要么把完整日志发布到最终名称,要么失败且不覆盖已有日志。平台分流只是实现细节;`SessionPersistence` API 和 JSONL 逻辑记录格式均不改变。后续的 [Zstandard 编码决策](2026-07-19-zstandard-jsonl-session-logs.zh.md)会先作用于不透明字节,然后才由任一平台执行发布。 Windows 测试会在原生 Windows 上执行真实的 Win32 发布路径。断电行为属于 API 约定属性,单元测试无法证明;可测试的不变量包括:Windows 物化不会调用目录 fsync、最终路径冲突会失败、达到最大长度的目标路径组件仍可物化、临时日志在发布前已经执行 fsync,并且生成的日志可以正常加载。 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 e7e49aa115..66dd29f3a4 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 .agents/notes/implemented/architecture/2026-07-06-timeout-deadline-library.md -2026-07-06-timeout-deadline-library.md: 38f048d16ecba0e5278ae34b0c88b7889dcfa47a -2026-07-06-timeout-deadline-library.zh.md: e8b0ab62b19d931c33027d5e6c9919804e80aa99 +2026-07-06-timeout-deadline-library.md: 95adc41bffff6d7711685ebc52cb73b2b455df41 +2026-07-06-timeout-deadline-library.zh.md: 8b7b18a2d1e7757102afc81bea03245de2707d86 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 38f048d16e..95adc41bff 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** (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/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. +- **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 — [packages/subprocess/subprocess-local/src/spawn.ts](../../../../packages/subprocess/subprocess-local/src/spawn.ts) — only reacts to aborts; [packages/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) owns the fused deadline and the `timedOut`/`aborted` classification. - **web_fetch** ([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/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 e8b0ab62b1..8b7b18a2d1 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**(当时位于 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/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `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/shell/bash-local/src/index.ts](../../../../packages/shell/bash-local/src/index.ts) 拥有融合的 deadline 以及 `timedOut`/`aborted` 分类。 - **web_fetch**([packages/web/web-fetch-http/src/provider.ts](../../../../packages/web/web-fetch-http/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 在本次设计中保持无超时——见「后果」。) @@ -103,7 +103,7 @@ export function timeoutOf(x: AbortSignal | { reason?: unknown }, code?: string): - `AbortSignal.any` 和 `using`/`Symbol.dispose` 在此首次进入本仓库(Node ≥ 24 基线,已满足)。 - 模型流现在共享一个可重启的定时器约定,不会把滑动的空闲间隔变成总调用截止时间,也不会计入消费方思考时间。能够观察到带外传输活动的适配器可以对尚未结算的 demand 调用 `pulse()`;被屏蔽的活动对 watchdog 仍不可见。该原语仍然只做通知;适配器测试证明其传输观察到稳定信号并终止。 -以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 +以下内容不在本次范围内,列出以标明边界:`web_search` 可以在其工具 schema 和快照覆盖规划完成后获得可选的面向模型的 `timeout_ms`;基于 ripgrep 的文件系统发现工具([打包的 ripgrep 搜索](2026-08-01-packaged-ripgrep-search.zh.md))通过 `dsh-tool-call-timeout-policy` 和 `exec.signal` 消费同样的提供方自有 deadline 形状;`tools/execute` waterfall(瀑布式事件)中间件可以通过驱动 `exec.signal` 为每次工具调用设置默认 deadline——那将是一个*消费*本库的插件,仍然只做通知,硬终止仍是各能力自己的事。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml index 5e943fed2b..554114fff0 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-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 .agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md -2026-07-06-tool-result-retention-library.md: 8d938db8f5fa78398a39e97cc877308200f7d60f -2026-07-06-tool-result-retention-library.zh.md: 0b2fe841beed21511c4088732713d3fd9fd9048f +2026-07-06-tool-result-retention-library.md: 464e3d51a0051487b7c29f0c01a11acb91d160e5 +2026-07-06-tool-result-retention-library.zh.md: 49361eec4b649d2d68dc36929baa0f9ff580eb68 diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md index 8d938db8f5..464e3d51a0 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.md @@ -16,7 +16,7 @@ The shared abstraction the tools need is **retention**, not generic collection. The library has two independent retainers: -- `ItemRetainer` handles ordered logical units such as paths, grep matches, or search sources. It supports `head` retention only in v1, while keeping the retainer shape open to additional retention strategies later. +- `ItemRetainer` handles ordered logical units such as paths, grep matches, or search sources. It supports only `head` retention, while keeping the retainer shape open to additional strategies. - `TextRetainer` handles byte-oriented text streams such as bash stdout/stderr or web response bodies. It supports `head`, `tail`, and `headTail` retention while preserving UTF-8 boundaries at `finish()`. Both retainers return a small `PushDecision` after each `push()` so callers can tell whether that unit/chunk was fully retained and whether the accumulated result is now truncated. Omission counts are exact because callers keep feeding every observed item/chunk. @@ -95,7 +95,7 @@ type TextRetentionStrategy = ### Tool mapping -`read` is intentionally outside the v1 retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`. +`read` is intentionally outside the retention library. Its `read-render` helper owns a file-specific pagination contract: `offset` / `limit`, line numbers, `totalLines`, offset-out-of-range errors, per-line preview truncation, and a selected-output byte cap that can stop scanning mid-window. That is a line-window renderer, not a generic retention primitive. It may share future neutral notice helpers, but it should not pass its already-selected window through `ItemRetainer`. `FsGlobEntry` and `FlatGrepMatch` below are the intended discovery-tool item shapes, not existing retention-library exports. `FsGlobEntry` is one backend-derived path, and `FlatGrepMatch` is one ungrouped grep match before the backend groups retained matches by file. @@ -142,15 +142,15 @@ The formatter hook is deliberately small: a tool turns a `RetentionNotice` into **Boundaries the library holds.** `truncated` means the retainer omitted otherwise-available content because of a budget; it never means the upstream was incomplete. Tool-specific states — `incomplete`, permission failures, provider partial failures, binary skips, bash spill-path recovery, invalid UTF-8 — stay in tool-domain fields, outside the retainer. When a future change migrates a tool, that package's README and tests must prove the model-facing result text is unchanged except for deliberate notice wording. -**Tradeoffs accepted.** The v1 API deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. +**Tradeoffs accepted.** The API deliberately supports only item `head` retention and text `head` / `tail` / `headTail`; windows, grouped budgets, sort-aware caps, and upstream-stop control wait until a second consumer proves the need. Text retention counts bytes for process/body safety, leaving character- and line-level preview budgets as separate tool-owned concerns. ## Alternatives considered **Post-hoc `truncate(text)` only.** Rejected: it matches Codex's history/tool-output truncation use case but loses item counts, grouping boundaries, UTF-8-safe byte windows, and exact omission metadata. -**One generic `Collector` with pluggable callbacks.** Rejected for v1: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small. +**One generic `Collector` with pluggable callbacks.** Rejected: it hides the two important resource modes. Logical item retention counts items; text retention counts bytes and preserves UTF-8 boundaries. Separate `ItemRetainer` and `TextRetainer` names make that difference explicit while keeping the API small. -**Put `read` windowing behind `ItemRetainer`.** Rejected for v1: `read` is the only current window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. +**Put `read` windowing behind `ItemRetainer`.** Rejected: `read` is the only shipped window consumer, and its semantics are file pagination rather than generic retention. A single `Omitted` count cannot represent both sides of a line window, and `read` also carries `totalLines`, offset-range errors, per-line preview truncation, and a byte cap over selected output. Keeping `read-render` tool-owned avoids growing the shared library around one special case. **Make truncation part of `ToolExecutionResult`.** Rejected: the tool registry would have to understand tool-specific recovery guidance, grouping, line numbering, exit status, and provider semantics. Retention is a library used by a tool's Native renderer; the model-facing projection remains tool-owned while the [canonical value](2026-07-20-canonical-tool-output-contract.md) may retain the complete acquired result. diff --git a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md index 0b2fe841be..49361eec4b 100644 --- a/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-06-tool-result-retention-library.zh.md @@ -16,7 +16,7 @@ Status: implemented 该库包含两个相互独立的 retainer: -- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。v1 只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。 +- `ItemRetainer` 处理有序逻辑单元,例如路径、grep 匹配项或搜索来源。它只支持 `head` 保留,同时维持 retainer 形态,以便未来加入其他保留策略。 - `TextRetainer` 处理面向字节的文本流,例如 bash stdout/stderr 或 web 响应正文。它支持 `head`、`tail` 和 `headTail` 保留,并在 `finish()` 时维持 UTF-8 边界。 两个 retainer 都会返回一个小型 `PushDecision`;每次调用 `push()` 后,调用方都能得知该单元/分片是否完整保留,以及累积结果此时是否已被截断。因为调用方会继续输入每一个已观察到的条目/分片,所以省略计数是精确的。 @@ -95,7 +95,7 @@ type TextRetentionStrategy = ### 工具映射 -`read` 被有意排除在 v1 保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。 +`read` 被有意排除在保留库之外。它的 `read-render` 辅助函数拥有文件专用的分页约定:`offset`/`limit`、行号、`totalLines`、offset 越界错误、逐行预览截断,以及能够在窗口中途停止扫描的所选输出字节上限。这是行窗口渲染器,不是通用保留原语。它未来可以共享中性的提示辅助函数,但不应把已经选定的窗口再传入 `ItemRetainer`。 下文的 `FsGlobEntry` 与 `FlatGrepMatch` 是预期由发现工具使用的条目形态,不是现有保留库的导出。`FsGlobEntry` 是一个由后端派生的路径;`FlatGrepMatch` 是后端将保留匹配项按文件分组之前的一条未分组 grep 匹配。 @@ -103,7 +103,7 @@ type TextRetentionStrategy = `grep` 在分组前使用 `ItemRetainer`,并将其配置为 `{ kind: 'head', maxItems: grepMaxMatches }`。执行器解析 ripgrep 输出、映射路径、应用逐行预览截断,并输入扁平匹配项。调用 `finish()` 后,工具按文件对保留的匹配项分组;如果行内结果达到上限,还可以通过 spill seam 保存完整匹配列表。分组不属于 retainer,因为上限针对匹配总数,而不是文件数;逐匹配项的预览截断和 `incomplete` 也与结果级保留相互独立。 -`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.md)相互独立。 +`bash` 可以使用 `TextRetainer`,配置为 `tail` 或 `headTail`,并读取至进程结束。bash 执行器仍负责 spill 文件、退出状态、信号、超时与后台任务行为;保留辅助函数只在需要该行为时替换临时实现的内存首尾核算。长时间运行任务的所有权与[通用长时间运行工具的运行时](2026-06-20-generic-long-running-tool-runtime.zh.md)相互独立。 `web_fetch` 可以使用 `TextRetainer`,配置为 `head` 或 `headTail`;如果提供方必须在内部读取和解码,也可以保留由提供方负责的正文上限。无论采用哪种方式,fetch 结果中的 `truncated` 仍是提供方/工具事实,该库只提供保留文本与省略元数据。 @@ -138,20 +138,20 @@ const formatGrepNotice = (notice: RetentionNotice): string => **已交付内容。** `@deepseek-ai/dsh-output-retention` 导出 `ItemRetainer`、`TextRetainer`、结果类型(`RetainedItems`、`RetainedText`)、策略类型(`ItemRetentionStrategy`、`TextRetentionStrategy`)、`Omitted`、`PushDecision`、`RetentionNotice`,以及中性的提示辅助函数 `describeOmitted`/`formatRetentionNotice`,且不依赖 Cordis 或任何工具包。单元测试覆盖具有精确省略计数的条目头部保留、文本头部保留、文本尾部保留、首尾字节保留、零预算、UTF-8 边界处理(2、3、4 字节码位,以及每个裁切位置上的无效起始字节)和未知省略量的措辞。 -**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 +**已记录但尚未迁移的内容。** `glob`、`grep`、`bash`、`web_fetch` 与 `web_search` 的映射已记录在[包 README](../../../../packages/util/output-retention/README.zh.md) 中,但本次改动并未把每个工具都迁移到该库;迁移工作刻意留作独立的后续任务。`read` 被明确记录为不在范围内:其 `read-render` 行窗口约定(`offset`/`limit`、`totalLines`、offset 范围错误、逐行预览截断,以及针对所选窗口的字节上限)不属于通用保留,而一个 `Omitted` 计数也无法同时表达行窗口两侧。 **该库维持的边界。** `truncated` 表示 retainer 因预算省略了原本可用的内容,绝不表示上游不完整。工具专用状态,包括 `incomplete`、权限失败、提供方局部失败、跳过二进制文件、bash spill 路径恢复和无效 UTF-8,均留在工具领域字段中、位于 retainer 之外。未来改动迁移某项工具时,该包的 README 与测试必须证明,除了有意改变的提示措辞外,模型可见的结果文本没有变化。 -**接受的取舍。** v1 接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。 +**接受的取舍。**接口刻意只支持条目的 `head` 保留,以及文本的 `head`/`tail`/`headTail` 保留;窗口、分组预算、感知排序的上限和上游停止控制,要等第二个消费方证明需求后再引入。文本保留按字节计数,以保障进程/正文安全;字符级和行级预览预算继续由具体工具负责。 ## 考虑过的替代方案 **只进行事后 `truncate(text)`。** 不予采纳:它适合 Codex 的历史/工具输出截断场景,却会丢失条目计数、分组边界、UTF-8 安全的字节窗口与精确省略元数据。 -**使用一个带可插拔回调的通用 `Collector`。** v1 不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。 +**使用一个带可插拔回调的通用 `Collector`。**不予采纳,因为它会掩盖两种重要的资源模式。逻辑条目保留按条目计数;文本保留按字节计数并维持 UTF-8 边界。独立的 `ItemRetainer` 与 `TextRetainer` 名称明确表达这种差异,同时保持 API 精简。 -**把 `read` 窗口交给 `ItemRetainer`。** v1 不予采纳:`read` 是当前唯一的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 +**把 `read` 窗口交给 `ItemRetainer`。**不予采纳:`read` 是唯一已交付的窗口消费方,其语义属于文件分页,而不是通用保留。一个 `Omitted` 计数无法表示行窗口两侧,而且 `read` 还携带 `totalLines`、offset 范围错误、逐行预览截断和针对所选输出的字节上限。让 `read-render` 由工具所有,可以避免共享库围绕一项特例膨胀。 -**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.md)可以保留完整的已采集结果。 +**让截断成为 `ToolExecutionResult` 的一部分。** 不予采纳:工具注册表将不得不理解工具专用的恢复指引、分组、行号、退出状态和提供方语义。保留是由工具的 Native renderer(原生渲染器)使用的库;模型可见投影继续由工具所有,而[规范值](2026-07-20-canonical-tool-output-contract.zh.md)可以保留完整的已采集结果。 **在每个面向模型的工具 schema 中公开上限。** 不作为默认方案:Claude Code 的 grep 公开 `head_limit`/`offset`,但本 harness 会把常规预算保留为部署配置,除非模型确实需要控制分页。未来可以为具体工具增加类似 read 的续传字段;它不属于共享保留原语。 diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml index 10a29ee499..373b1293ce 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.md 2026-07-07-tool-call-timeout-policy.md: 92618cc8c761b38d7e516c9d00eb3de1c37831a8 -2026-07-07-tool-call-timeout-policy.zh.md: cc633ceaa3840331826f6475e603e78a98f0afd2 +2026-07-07-tool-call-timeout-policy.zh.md: 0303fadd134c6eb8c41449823b62f1458f4523ba diff --git a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md index cc633ceaa3..0303fadd13 100644 --- a/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-07-tool-call-timeout-policy.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 +[超时/截止时间 Agent Note](2026-07-06-timeout-deadline-library.zh.md) 将计时与分类原语提取到了 `@deepseek-ai/dsh-timeout`,但超时策略仍然附着在各个能力和面向模型的 schema 上。`bash` 暴露了 `timeoutMs`;`web_fetch` 暴露了 `timeout_ms`;`web_search` 没有面向模型的超时参数,尽管提供方已经遵循 `exec.signal`;未来的 grep/glob 工具要么直接导入超时库,要么自行发明超时策略。对于一个插件 SDK 来说,这是错误的编写范式:工具作者通常只需将 `exec.signal` 转发给其调用的实现,而部署策略来决定预算。 与此同时,仓库中并非所有超时都是面向模型的工具调用预算。钩子通过直接调用 `ctx.shell` 执行命令钩子,而非通过 `ctx.tools.execute()`;`bash` 模型工具通过同一个后端复用前台执行、后台启动、后台轮询和钩子复用。一步到位地将所有超时移入工具插件会混淆这些路径,并有破坏钩子超时语义的风险。 @@ -16,7 +16,7 @@ Status: implemented - `@deepseek-ai/dsh-timeout` 仍是拥有 `deadline()` 和 `timeoutOf()` 的共享库。 - `@deepseek-ai/dsh-tools` 在 `tools/pre-execute` 和 `tools/post-execute` 之间有一个环绕分发的 waterfall(瀑布式事件)`tools/execute`。 -- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 +- [仓库命名约定](2026-08-11-repository-naming-contract-and-rename-ledger.zh.md)使用 `@deepseek-ai/dsh-tool-call-timeout-policy`,准确说明该策略所限制的操作。插件从 runtime 读取每个工具声明的 `timeoutMs`,并通过派生新的 `exec.signal` 来包装有此声明的调用。 执行流水线如下: @@ -52,7 +52,7 @@ catch 是基础 `next`(而非 waterfall 之外的东西)这一点至关重 searchTimeoutMs: 30000 ``` -超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 +超时放在工具定义上而非自由文本名称映射中,消除了拼错名称导致策略不生效的问题。`defineTool` 校验预算为正有限数。分发期间,执行器派生截止信号并将其赋给 `exec.signal`;注册表依据[工具取消约定](2026-07-19-cooperative-tool-cancellation.zh.md),在执行工具体之前将该截止信号与调用方的原始信号融合。执行器随后恢复调用方信号,并将自身的超时转换为 `TOOL_TIMEOUT`;没有预算的工具原样通过。 信号替换采用**就地修改 `exec.signal`** 的方式,而非向 `next()` 传递新对象。Cordis 的 waterfall `next()` 忽略传入的任何参数,并以共享的 payload 数组重新调用下游监听器(`vendor/cordis/src/events.ts`),因此修改共享对象是包装器向注册表提供截止信号的方式。注册表会在进入工具体前再次融合已捕获的调用方信号;插件则在 `finally` 中将 `exec.signal` 恢复为调用方的原始值,使 `tools/post-execute` 永远不会看到本插件的截止信号。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml index efed9ca5ea..20d0e4f85c 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.md 2026-07-08-agent-scope-contexts.md: eb3f6f247bac1a1d81aa2644132c7b9cc04d602c -2026-07-08-agent-scope-contexts.zh.md: fe82ac18d07de97330e86461e6bdf86edc37d2ae +2026-07-08-agent-scope-contexts.zh.md: a0f4ffb0ef80dd2fc1ee61c9ab3f4730c28c687e diff --git a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md index fe82ac18d0..a0f4ffb0ef 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-agent-scope-contexts.zh.md @@ -16,7 +16,7 @@ Status: implemented 每个存活的 agent 拥有一个扁平的注册层,通过 `agent.ctx` 暴露。代码通过拥有某项贡献的上下文进行注册;具备作用域感知的服务将部署全局注册与恰好一个匹配的 agent 层合并;操作从其真实 agent 选择该层;该层在 agent 的完整发布生命周期内存在。 -Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.md)对该框架有更详细的说明。 +Cordis 是 SDK 底层的插件框架。Cordis **上下文**是插件用来访问服务和注册效果的对象,效果的清理跟随该上下文。[Cordis 入门](../../../../docs/cordis-primer.zh.md)对该框架有更详细的说明。 对大多数贡献者而言,完整约定是四条规则: @@ -45,7 +45,7 @@ flowchart LR 缺失的交叉边即隔离规则:Agent A 的本地注册不会进入 Agent B 的视图,父级的注册也不会仅因父级拥有子级的生命周期就进入子级。 -配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 +配套的[运行时设计 Agent Note](2026-07-12-agent-scope-runtime-design.zh.md) 阐述了实现与正确性推理。[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 负责独立的 `persona`、`toolFilter` 和 `maxDepth` 功能。 ### 注册来源决定可见性与清理 @@ -104,7 +104,7 @@ setup 接收一个完整的受信 Cordis 上下文,因此可以组合普通插 在 Cordis 层面,`Scoped` 是一个不透明的路由接收器。它携带用于选择监听器的过滤器,但本身不是领域对象。因此事件签名将真实的 `Agent`、工具执行、审批请求或其他主体作为显式参数保留,供监听器检查。 -以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。 +以 `{ global: true }` 注册的监听器有意绕过上下文受众过滤,但其清理仍跟随注册上下文。注册表成员变更通知保持不过滤,因为它们描述的是共享注册表状态而非某个 agent 的操作。详尽的事件参考是各[子系统页面](../../../../docs/subsystems/core.zh.md)上生成的 `cordis-surface` 区块的集合——每个事件作用域在其所属页面上(`agent/*` 与 `agent-loop/*` 在 core.md 本页)。 ### 创建最后发布,dispose 最后撤销 @@ -138,6 +138,8 @@ flowchart TB detach --> revoke["Dispose the agent scope"] ``` + + ## 安全与权限是非目标 agent 作用域组合的是受信的同进程注册。它不沙箱化插件、不定义父到子的权限格、不在创建时冻结授权、也不保证子级不能做超出父级的事。 @@ -146,6 +148,8 @@ agent 作用域组合的是受信的同进程注册。它不沙箱化插件、 需要非升权保证的部署需要独立的权限表示、传播规则和执行检查。父级子集授权、创建时授权快照、显式未来授权 API,以及通用的能力/输出/终止标签均不在本决策范围内。 + + ## 曾考虑的替代方案 被否决的设计要么将可见性与清理分离,要么只覆盖一类注册,要么重复共享基础设施,要么将生命周期所有权与继承混为一谈。 diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml index 5f9f8ba421..f1135aa2b3 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md -2026-07-08-tool-output-spill-files.md: 4d1d4b7b665f34b362df2f8c8aeb06d96bf2668f -2026-07-08-tool-output-spill-files.zh.md: 1a3387830861c6d05318024870325523b8eeee36 +2026-07-08-tool-output-spill-files.md: 915e22f1245adb6f7cfc7d358e9d5802531bab63 +2026-07-08-tool-output-spill-files.zh.md: 8d08b05483a302f4188506531da6f507931bea9c diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md index 4d1d4b7b66..915e22f124 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.md @@ -35,7 +35,7 @@ interface SpillStore { interface SpillSource { toolName: string - callId: CallId + callId: ToolCallId label: string } @@ -57,7 +57,7 @@ interface SpillRef { `SpillLocator` is a [branded](../../../../packages/util/brand) model-facing handle returned by the backend. The local backend renders it as a filesystem path; a remote or database backend can render a URI, key, or command token. Consumers treat it as opaque and render it with `retrievalHint` instead of assuming `read` is always the right retrieval mechanism. `SpillOwner.sessionId` is the save-time storage namespace: forked sessions inherit existing spill locators from the seeded log without copying or re-owning them, and new spills after the fork use the child session id. A retention-period cleanup may expire old locators with other old session artifacts; the spill seam does not define a per-session cleanup policy. -`dsh-spill-local` owns only storage details: session-scoped directory selection, safe names, path-traversal protection, the write, and returning `{ locator, bytes, retrievalHint }`. It does not own retention policy, tool-result replacement, search, or file inspection. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path. +`dsh-spill-local` owns storage details: session-scoped directory selection, safe names, path-traversal protection, the write, local artifact lifetime, and returning `{ locator, bytes, retrievalHint }`. It does not own tool-result replacement, model-facing preview policy, search, file inspection, or a seam-wide/per-session retention policy. Files land at `/session-/-`, where `root` is a configured path or a lazily-created private (0700) per-process temp dir, the session subdir is a short `sha256(sessionId)` prefix, and the leaf is a random hex prefix plus the caller's `suggestedName` sanitized to one path segment (mirrors the JSONL backend's `encodeSegment`). The write is `open(path, 'wx', 0o600)` — exclusive and owner-only, so a planted symlink cannot redirect it. The locator is the path, and the retrieval hint tells the model it can use `read` or `grep` on that path. Its one-shot startup cleanup applies the backend-specific artifact lifetime described in the [local spill cleanup note](./2026-07-17-local-spill-startup-cleanup.md). ### Spill policy @@ -147,8 +147,8 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa ## Non-goals -- No new model-facing `artifact_read` or `artifact_search` tool in v1. -- No per-tool retention configuration in v1. +- This decision adds no model-facing `artifact_read` or `artifact_search` tool. +- This decision adds no per-tool retention configuration. - No model-facing timeout/truncation arguments. - No migration of `read` output into spill files. - No replacement for provider/resource caps such as `web-fetch-http.maxBodyChars`. @@ -160,7 +160,8 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa - Tool-owned spill for subagent rollouts (`await run.result`, read in-process child session before `run.dispose()`, save JSONL). - Per-tool opt-out or per-tool policy declarations if the built-in `read` skip is insufficient. - Remote or database storage backends for ACP or remote environments where a local path is not meaningful. -- Cleanup and retention policy for old spill files, likely tied to session cleanup. + +Cleanup shipped for the local backend as a one-shot startup sweep, not tied to session deletion — see the [startup-cleanup Agent Note](./2026-07-17-local-spill-startup-cleanup.md). The seam still defines no per-session cleanup policy; retention is a backend concern. ## Testing @@ -174,9 +175,9 @@ Those cases can consume `ctx.spillStore` directly in later work. They are not pa The default policy only sees final formatted text. It cannot preserve provider-internal content that was already capped or runtime artifacts that were never part of the result. This is acceptable for the first cut because the showcase is final-result spill, not early spill; tool-owned early spill remains deferred work. -Returning real paths from the local backend keeps v1 simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators. +Returning real paths keeps the local backend simple and matches proven agent-tool behavior, while the seam itself only promises an opaque locator plus retrieval hint so remote backends can return non-file locators. -The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds today because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader. +The local-backend value proposition depends on the existing `read`/`grep` tools being able to inspect the returned local path, even when the spill directory is outside the session cwd. That holds because the filesystem policy records observations and write guards but does not confine reads to the workspace. A future workspace-confinement policy must either allow local spill paths explicitly or use a non-file spill backend whose retrieval hint points at a supported reader. **Snapshot gap.** No ACP snapshot scenario covers the transcript-visible `web_fetch` spill notice yet. The ACP snapshot harness replays keyless and cannot hit the live web, and a `web_fetch` spill requires a real over-cap HTTP body; a deterministic scenario would need a seeded loopback fetch target the replay tree does not currently wire (the examples do not load `tool-web` at all). The behavior is covered instead by the `dsh-tool-web` integration test against a loopback server. Closing the gap is follow-up work: wire `tool-web` + a seeded fetch target into the ACP example, then record a `web-fetch-spill` scenario. @@ -184,7 +185,7 @@ The policy can become too large if it starts owning tool-specific semantics. It ## Alternatives considered -**Require each tool to opt in with a retention declaration.** Rejected for v1: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape. +**Require each tool to opt in with a retention declaration.** Rejected: the goal is a default behavior similar to Claude Code's generic tool-result persistence. A single `maxInlineBytes` deployment knob is enough to prove the shape. **Make `tool-results` a broad tool-result platform.** Rejected: a broad package name invites retention policy, result replacement, preview wording, search, and early spill into one seam. The shared storage part is smaller: save text and return a locator plus retrieval hint. diff --git a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md index 1a33878308..8d08b05483 100644 --- a/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-08-tool-output-spill-files.zh.md @@ -8,9 +8,9 @@ Status: implemented 工具输出需要有界的模型可见预览,但部分超大结果仍可能在之后有用。抓取的页面正文或冗长的工具响应不应完整占用下一次模型请求,但模型应能使用现有文件读取工具,在之后查看经过格式化的完整结果。 -这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 +这项改动之前的行为并不一致。`dsh-bash-local` 已经会在内存尾部溢出时,把完整 stdout/stderr 流写入私有的临时 spill 文件;普通文本工具结果则仍以内联形式返回,除非工具自行实现上限。[工具结果保留库](2026-07-06-tool-result-retention-library.zh.md)负责预览机制,但不负责存储,也不负责把这些机制应用于最终工具结果的执行流水线策略。 -其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.md)规定了这项区分。 +其形态与超时策略设计一致:工具作者声明规范值与 Native renderer(原生渲染器),由策略插件在渲染后的内容上执行部署默认的上下文预算。工具仍可在提供方采集上限处提前 spill;由工具负责的展示 spill 可以保留已完整采集的规范值,而只替换展示内容。[规范工具输出约定](2026-07-20-canonical-tool-output-contract.zh.md)规定了这项区分。 ## 决策 @@ -35,7 +35,7 @@ interface SpillStore { interface SpillSource { toolName: string - callId: CallId + callId: ToolCallId label: string } @@ -57,7 +57,7 @@ interface SpillRef { `SpillLocator` 是一个[品牌化的](../../../../packages/util/brand)模型可见句柄,由后端返回。本地后端将其渲染为文件系统路径;远程或数据库后端可以渲染 URI、键或命令 token。消费方把它视为不透明值,并使用 `retrievalHint` 渲染,而不是假定 `read` 始终是正确的检索机制。`SpillOwner.sessionId` 是保存时的存储命名空间:fork 后的会话会从种子日志继承已有的 spill 定位符,无需复制它们或重新取得所有权;fork 后的新 spill 使用子会话 id。保留期清理可以连同其他旧会话产物一起使旧定位符失效;spill seam 不定义逐会话的清理策略。 -`dsh-spill-local` 只负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入,以及返回 `{ locator, bytes, retrievalHint }`。它不负责保留策略、工具结果替换、搜索或文件检查。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。 +`dsh-spill-local` 负责存储细节:选择会话作用域的目录、安全名称、防止路径遍历、执行写入、本地产物生命周期,以及返回 `{ locator, bytes, retrievalHint }`。它不负责工具结果替换、模型可见的预览策略、搜索、文件检查,也不定义 seam 级或逐会话保留策略。文件写入 `/session-/-`:`root` 是配置路径,或延迟创建的私有(0700)进程级临时目录;会话子目录是 `sha256(sessionId)` 的短前缀;叶节点由随机十六进制前缀与调用方的 `suggestedName` 组成,后者会被清理成单一路径段(与 JSONL 后端的 `encodeSegment` 一致)。系统使用 `open(path, 'wx', 0o600)` 写入,确保独占且仅所有者可访问,因此预先植入的符号链接无法重定向写入。定位符就是该路径,检索提示则告知模型可以在该路径上使用 `read` 或 `grep`。它的一次性启动清理会应用[本地 spill 清理说明](./2026-07-17-local-spill-startup-cleanup.zh.md)所述的后端专属产物生命周期。 ### spill 策略 @@ -147,8 +147,8 @@ ctx.tools.register(defineTool({ ## 非目标 -- v1 不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。 -- v1 不增加逐工具的保留配置。 +- 本决策不增加面向模型的 `artifact_read` 或 `artifact_search` 工具。 +- 本决策不增加逐工具的保留配置。 - 不增加面向模型的超时/截断参数。 - 不把 `read` 输出迁移到 spill 文件。 - 不取代 `web-fetch-http.maxBodyChars` 等提供方/资源上限。 @@ -160,7 +160,8 @@ ctx.tools.register(defineTool({ - 由工具负责的 subagent 执行轨迹 spill(`await run.result`,在 `run.dispose()` 前读取进程内子会话,保存 JSONL)。 - 如果内置的 `read` 跳过规则不足,再增加逐工具选择退出或逐工具策略声明。 - 面向 ACP(Agent Client Protocol)或远程环境的远程/数据库存储后端,因为本地路径在这些环境中没有意义。 -- 旧 spill 文件的清理和保留策略,很可能与会话清理绑定。 + +本地后端通过一次性启动扫描清理旧文件,而不是绑定到会话删除——参见[启动清理 Agent Note](./2026-07-17-local-spill-startup-cleanup.zh.md)。seam 仍未定义逐会话清理策略;保留策略属于后端。 ## 测试 @@ -174,9 +175,9 @@ ctx.tools.register(defineTool({ 默认策略只能看见最终格式化文本。它无法保留已经由提供方限制的内部内容,也无法保留从未成为结果一部分的运行时产物。第一版聚焦最终结果 spill 而不是提前 spill,因此可以接受这一限制;由工具负责的提前 spill 仍属于后续工作。 -本地后端返回真实路径,使 v1 保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 +本地后端返回真实路径,使其保持简单并符合已经验证的 agent(智能体)工具行为;seam 本身只承诺一个不透明定位符加检索提示,所以远程后端可以返回非文件定位符。 -本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使 spill 目录位于会话 cwd 之外。目前这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地 spill 路径,或改用检索提示指向受支持读取器的非文件 spill 后端。 +本地后端的价值取决于现有 `read`/`grep` 工具能否检查返回的本地路径,即使 spill 目录位于会话 cwd 之外。这一条件成立,因为文件系统策略会记录观察结果并设置写保护,但不会把读取限制在工作区内。未来的工作区限制策略必须显式允许本地 spill 路径,或改用检索提示指向受支持读取器的非文件 spill 后端。 **快照缺口。** 目前没有 ACP 快照场景覆盖 transcript(文本记录)可见的 `web_fetch` spill 提示。ACP 快照 harness 在无密钥环境中回放,无法访问实时 web,而 `web_fetch` spill 需要一个真实的超上限 HTTP 正文;确定性场景需要一个预置的 loopback fetch 目标,但当前回放树尚未接线(示例根本没有加载 `tool-web`)。该行为改由 `dsh-tool-web` 针对 loopback server 的集成测试覆盖。弥补该缺口属于后续工作:把 `tool-web` 和预置 fetch 目标接入 ACP 示例,然后录制 `web-fetch-spill` 场景。 @@ -184,7 +185,7 @@ ctx.tools.register(defineTool({ ## 考虑过的替代方案 -**要求每个工具通过保留声明选择加入。** v1 不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 +**要求每个工具通过保留声明选择加入。**不予采纳,因为目标是实现类似 Claude Code 通用工具结果持久化的默认行为。只需一个 `maxInlineBytes` 部署配置项即可验证该形态。 **把 `tool-results` 建成宽泛的工具结果平台。** 不予采纳:宽泛的包名会诱使系统把保留策略、结果替换、预览措辞、搜索和提前 spill 合并进一个 seam。可共享的存储部分更小:保存文本,并返回定位符与检索提示。 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 0321d494a0..ec2f5318d6 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md 2026-07-10-after-call-compaction-pressure-and-overflow-recovery.md: 89c8917484c11057ff2e6b55e2311e83f724f7ed -2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 96f7aa1b0ea4e44ca6318caca2c785d898b1d00d +2026-07-10-after-call-compaction-pressure-and-overflow-recovery.zh.md: 48f954f2c4720894548ecab387965d3a3d1799b0 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 96f7aa1b0e..48f954f2c4 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 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 ### 请求恢复只覆盖最终模型边界 -`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.md)规定这一规范化边界。 +`agent/request-error` 表示来自最终适配器边界的终止失败。适配器选择、分发、iterator 构造与迭代抛出会在 agent loop(智能体循环)消费前成为终止 `error` 或 `aborted` finish;适配器直接发出的终止 finish 进入同一路径。提示词装配、请求 middleware、请求日志、结果处理、工具、step 监听器与清理仍属于普通失败。[LLM(大语言模型)流的终止失败](2026-07-29-terminal-llm-stream-failures.zh.md)规定这一规范化边界。 -恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.md)规定这一返回边界。 +恢复运行前,失败 step 已经关闭。负责处理的监听器修复持久状态、返回 `{ kind: 'retry' }`,并停止 waterfall(瀑布式事件)委托。循环随后关闭失败 turn,并从持久日志开启一个重试 turn,中间不发布空闲通知。重试策略与尝试计数由插件自己拥有;compaction-basic 在链路到达终态 `agent/settled` 时清除对应 agent 的溢出计数。两个 DeepSeek 适配器都把识别出的提供方上下文限制错误规范化为 `CONTEXT_WINDOW_EXCEEDED`。[重试动作决策](../simplification/2026-07-27-request-error-retry-action.zh.md)规定这一返回边界。 如果取消发生在 assistant 工具调用已经持久化之后、所有调用完成分发之前,循环会为每个尚未分发的调用记录一对合成的 `tool/call` 与 aborted `tool/result`,随后进入正常中止路径。因此,表层不会仅因取消赢得竞态而留下孤立的持久工具调用。 @@ -58,4 +58,4 @@ Compact-basic 会在每个拟议请求之前包装 `agent/pre-step`。在续步 代价是在共享 pre-step waterfall 中执行压力工作,并需要适配器持续维护溢出分类。提供方措辞与启发式字符密度仍是维护风险。表层压缩依然无法修复仅信封本身就超出窗口的情况,也不能拆分不可分割的非工具节点,或修复不可剪枝的剩余部分仍然过大的工具单元。若可移除的文本工具结果是主要体积,可选剪枝器仍可修复原本不可分割的工具配对。 -[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 +[已领取 pre-step 生命周期](2026-07-31-claimed-pre-step-inbox-lifecycle.zh.md)取代了本记录原先的 post-step 触发方式。服务拆分、独立 token meter、平衡范围约定、日志中记录的锁、摘要替换与唯一 `summarize()` 子类 hook 均保持不变。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 21c607072c..83638635ad 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: 40433d99e5d1aa569c3fdf094a280d3de62ad588 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: 54030fa4b0742fbc282bc327b0ca22747e6a20bd +2026-07-10-single-file-executable-sdk-runtime-distribution.md: c152345772826ec4e2dbfd238726c429418c7897 +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: ea5e457afd761cb5071f8b584ef10fa7ffaa8210 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index 40433d99e5..c152345772 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -23,40 +23,40 @@ The exe is packaged with the **`--sea` (enhanced SEA) mode** of [@yao-pkg/pkg](h Terminology reminder: pkg's `/snapshot` VFS has nothing to do with this repo's testing-system "snapshot" (ACP replay expected outputs, `$DSH_SNAPSHOT`); this document says "VFS" for the former. -### The serving interface is a plugin: the two packages sdk/server + examples/jsonrpc-demo +### The serving interface is a plugin inside the dsh application -The deterministic protocol implementation (`server.ts` / `transport.ts`) lands as two packages on the existing `acp/acp` + `examples/acp-demo` pattern — the serving surface is itself a plugin: +The deterministic serving surface is a plugin selected by the packaged `dsh` application: - [`packages/sdk/server`](../../../../packages/sdk/server/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-server`): the pure protocol plugin; on apply it mounts `HarnessSdkJsonRpcServer` plus a line-delimited JSON-RPC transport on the process stdio, with disposal through `ctx.effect()`. Whether to serve is decided by `cordis.yml`; a yml that does not mount it is a legitimate process that does not serve. Protocol-level exit belongs to the plugin (after answering and flushing the `shutdown` response it disposes the root runtime so persistence drains, then `exit(0)`; an HMR-style unload only stops the service without exiting the process). -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md) (`@deepseek-ai/dsh-sdk-jsonrpc-demo`): a thin app bin — `installFailLoud` + `loadEnv` + config discovery + `boot()` from [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts), done once boot completes; the server is brought up by the `dsh-sdk-jsonrpc-server` entry in the yml. Its only dependency is app-boot. Process-level exit belongs to the bin (stdin EOF/SIGTERM → dispose then 0, SIGINT → 130). +- [`apps/cli`](../../../../apps/cli/README.md) (`@deepseek-ai/dsh`): the packaged application entry; its `sdk` profile mounts `dsh-sdk-jsonrpc-server`, and the CLI owns environment layering, profile composition, stdin/signal shutdown, and process exit. -Config discovery has two channels and fails loudly when both are missing: the `DSH_CORDIS_CONFIG` environment variable first (the SDK client convention), then an argv positional argument; no default path and no built-in fallback whatsoever — "the plugins actually booted are decided by an external cordis.yml" is a hard semantic. +The Python client supplies an explicit Harness home and selects the `sdk` profile plus ordered patch files. A missing home, profile, bundle, or server row fails loudly; there is no external complete-config fallback. The [Python profile-runtime decision](2026-08-23-python-sdk-dsh-profile-runtime.md) owns this application surface. ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. -The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `apps/cli/config/agent-presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. +The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-python-runtime-closure`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) reads every shipped `packages/preset/agent-presets/presets/*/agent.cordis.yml`, evaluates `disabled` conditions that compare `process.platform` for every target in `python/sdk-runtime/platforms.json`, and requires each active workspace plugin at the runtime root through an explicit `workspace:` dependency. It also traverses every workspace package covered by that manifest and requires every non-optional workspace peer, reporting the complete preset or referencing-package → missing-dependency chain; unknown platform conditions remain active so a plugin cannot be omitted by an unsupported expression. `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. The deploy root includes `@deepseek-ai/dsh-mcp-client` as an explicitly supported custom-configuration plugin even though no shipped preset mounts it. An external config can therefore connect to user-supplied stdio and Streamable HTTP MCP servers and register their tools; the distribution does not carry those servers or extend the bridge to MCP Resources and Prompts. The executable and installed-wheel smokes start a temporary stdio server, discover its tool, and complete one model-requested call. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source; CI rebuilds that addon inside the matching manylinux 2.28 container before packaging, and the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore direct workspace packages omitted by legacy deploy and reject any remaining manifest gap → replace staged dependency symlinks with their target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject pkg configuration whose bin is `node_modules/@deepseek-ai/dsh/lib/bin.js` and whose assets cover dynamic profile, bundle, frontend, preset, native-library, and configuration reads → stage the target `node-pty` addon → invoke `pkg --sea` once per target → write `deepseek-harness-sdk-runtime--` under `dist-exe/` and copy it into the runtime directory. Linux CI rebuilds `pty.node` inside the matching manylinux 2.28 container because legacy deploy omits that install side effect. Every target copies its native `@vscode/ripgrep` binary beside the executable as the required `-rg` sidecar; pkg runtimes select that sidecar through `process.pkg`, while ordinary Node execution uses `@vscode/ripgrep` directly. macOS uses its target prebuild and also emits the required `-spawn-helper`. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. -CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), called for linux-x64 by the [required Python runtime pull-request validation](../testing/2026-08-12-required-python-runtime-pull-request-ci.md), triggered explicitly by `workflow_dispatch` or the `build-exe` label for selected targets, and called for all targets by the [public publication workflow](../process/2026-08-11-python-publication-workflow.md). Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached, and pkg handles macOS ad-hoc signing. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects both the executable and native addon's GLIBC requirements and runs in a manylinux 2.28 container, while macOS verifies that the executable's deployment target fits the wheel tag. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. +CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml) is called for all four targets by the [installed-wheel Python runtime pull-request validation](../testing/2026-08-23-installed-python-wheel-black-box-ci.md) and the [public publication workflow](../process/2026-08-11-python-publication-workflow.md); `workflow_dispatch` can still select a subset. Native builds run on linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64 / win-x64 (`windows-2025`), with `~/.pkg-cache` cached where applicable, and pkg handles macOS ad-hoc signing. Each leg installs the release-shaped SDK and runtime wheels into a clean venv outside the checkout, proves their package and executable provenance, then drives the complete keyless scenario set through the public SDK and direct NDJSON JSON-RPC. Trusted pull requests additionally run a real DeepSeek two-turn tool smoke on every target; fork and Dependabot heads receive no key. Linux inspects the executable and native addon's GLIBC requirements and runs an additional manylinux 2.28 smoke, while macOS verifies that the executable's deployment target fits the wheel tag. A full four-target run retains five artifacts, each containing one release file: the platform-independent SDK wheel and four native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts `python-v` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and four native runtime wheels, then a single serialized job checks and publishes all five to the project PyPI registry. The [Windows x64 runtime decision](2026-08-23-python-sdk-windows-x64-runtime.md) owns the fourth target and the explicit exclusion of Windows arm64. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe with its required `-rg` sidecar and optional macOS helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` is the client and `python/sdk-runtime` is the runtime carrier package. The runtime package's data directory holds the build-injected platform executable with its required `-rg` sidecar and optional macOS helper, plus the build-injected `runtime/node/` closure tree for repository development. `resolve_bundled_launch_args()` selects the executable by default; explicit `DSH_RUNTIME_MODE=node` runs `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js` on system Node 22.19 or newer. The node carrier never enters wheel distributions, and neither carrier uses a checked-in complete `cordis.yml`. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched `-rg` sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative `X.Y.Z` or prerelease version from the repository root `package.json`, converts prereleases to their PEP 440 spelling, and stages both packages at that wheel version, with `deepseek-harness-sdk` depending exactly on the matching `deepseek-harness-runtime-bin`. An optional `python-v` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. Staging also carries the repository license into both wheels and the third-party notices into the bundled runtime wheel. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe and its architecture-matched ripgrep sidecar, and the macOS wheel also contains its architecture-matched spawn helper. Runtime wheels use `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, the conservative `py3-none-macosx_14_0_arm64` tag for the Node 24 executable's macOS 13.5 deployment target, or `py3-none-win_amd64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra sidecars, and unsupported platforms. -The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-sdk-jsonrpc-server` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. +The Python client launches the packaged `dsh` command with the selected profile (`sdk` by default), ordered patch files, and an explicit Harness home. The profile owns JSON-RPC serving and application composition; missing homes, profiles, bundles, patches, and server rows fail without an external complete-config fallback. ### Naming lineage -`@deepseek-ai/dsh-sdk-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. +`dsh-python-runtime-closure` is the private deploy manifest and `deepseek-harness-sdk-runtime--` is the executable family. The wire `serverInfo.name` is `deepseek-harness-sdk-runtime`; the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules are `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins @@ -64,7 +64,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build installs both wheels into a clean venv outside the checkout, proves matching versions and installed module/executable locations, then completes turns against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The filesystem-search scenario requires the model to call both `glob` and `grep` through the target-native `-rg` sidecar. The MCP scenario starts a temporary external stdio server, deliberately delays its initial `tools/list` response, then immediately starts the first SDK prompt; the prompt must see and call the discovered tool, proving that `initialize` is a real Loader-settlement readiness boundary rather than a timing sleep. The same installed run compares a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message, agent, workflow-run, and session IDs across the SDK result and notification stream plus the parent and two child JSONL logs. Trusted pull requests add a real-provider two-turn file write/read whose external bytes, tool calls, completed reasons, and persisted log must agree. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index 54030fa4b0..ea5e457afd 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -23,40 +23,40 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 术语提醒:pkg 的 `/snapshot` VFS 与本仓库测试体系的「快照」(ACP(Agent Client Protocol)回放预期输出、`$DSH_SNAPSHOT`)无关,本文用「VFS」指前者。 -### 对外服务接口也是插件:sdk/server + examples/jsonrpc-demo 两个包 +### 对外服务接口是 dsh 应用中的插件 -确定性协议实现(`server.ts` / `transport.ts`)按 `acp/acp` + `examples/acp-demo` 的既有模式落为两包——对外服务接口本身也是插件: +确定性服务接口由打包后的 `dsh` 应用选择为插件: -- [`packages/sdk/server`](../../../../packages/sdk/server/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 -- [`packages/examples/jsonrpc-demo`](../../../../packages/examples/jsonrpc-demo/README.md)(`@deepseek-ai/dsh-sdk-jsonrpc-demo`):轻量应用入口——`installFailLoud` + `loadEnv` + 配置发现 + [`dsh-app-boot`](../../../../packages/boot/app-boot/src/index.ts) 的 `boot()`;`boot()` 完成后入口即完成,服务器由 `cordis.yml` 中的 `dsh-sdk-jsonrpc-server` 条目启动。它只依赖 `app-boot`。进程级退出归 `bin` 所有(stdin EOF/SIGTERM → dispose 后返回 0,SIGINT → 130)。 +- [`packages/sdk/server`](../../../../packages/sdk/server/README.zh.md)(`@deepseek-ai/dsh-sdk-jsonrpc-server`):纯协议插件;执行 `apply` 时,在进程 stdio 上挂载 `HarnessSdkJsonRpcServer` 与按行分隔的 JSON-RPC 传输层,资源释放走 `ctx.effect()`。是否提供服务由 `cordis.yml` 决定;未挂载该插件的配置会启动一个不提供此服务的合法进程。协议级退出归插件所有(应答并确保 `shutdown` 响应发送完毕后,对根运行时执行 dispose(资源释放),让待处理的持久化操作完成,再调用 `exit(0)`;HMR(热模块替换)式卸载只停止服务,不退出进程)。 +- [`apps/cli`](../../../../apps/cli/README.zh.md)(`@deepseek-ai/dsh`):打包后的应用入口;其 `sdk` profile 挂载 `dsh-sdk-jsonrpc-server`,CLI 负责环境分层、profile 组合、stdin/signal 关闭与进程退出。 -配置发现有两个通道,均缺失时立即报错:优先使用 `DSH_CORDIS_CONFIG` 环境变量(SDK 客户端约定),其次使用 argv 位置参数;没有默认路径或内置回退——「实际启动的插件由外部 `cordis.yml` 决定」是硬语义。 +Python 客户端提供显式 Harness home,并选择 `sdk` profile 与有序 patch 文件。缺失 home、profile、bundle 或 server 配置项都会明确失败;不存在外部完整配置回退。[Python profile 运行时决策](2026-08-23-python-sdk-dsh-profile-runtime.zh.md)负责该应用接口。 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 -部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `apps/cli/config/agent-presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 +部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-python-runtime-closure`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 读取每个已发布的 `packages/preset/agent-presets/presets/*/agent.cordis.yml`,针对 `python/sdk-runtime/platforms.json` 中的每个目标解析比较 `process.platform` 的 `disabled` 条件,并要求该目标启用的每个工作区插件都通过显式的 `workspace:` 依赖列在运行时根目录。它还遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列出,并报告“preset 或引用包 → 缺失依赖”的完整链路;无法识别的平台条件会保持启用,避免因不支持的表达式遗漏插件。`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 部署根目录显式包含 `@deepseek-ai/dsh-mcp-client`,将其作为自定义配置可用的插件,即使随附 preset 均未挂载该插件。外部配置因此可以连接由用户提供的 stdio 与 Streamable HTTP MCP server 并注册其工具;分发物不包含这些 server,也不将桥接范围扩展到 MCP Resources 和 Prompts。可执行程序与已安装 wheel 包的冒烟测试会启动临时 stdio server,发现其工具,并完成一次由模型请求的调用。 ### 构建流水线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`;CI 会在打包前进入匹配架构的 manylinux 2.28 容器重新构建该 addon,而 `--legacy` 部署会省略这一副作用目录,因此构建器会把它从根安装目录复制到暂存闭包。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-python-runtime-closure deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复 legacy deploy 遗漏的直接工作区包,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置,其中 bin 为 `node_modules/@deepseek-ai/dsh/lib/bin.js`,assets 覆盖动态读取的 profile、bundle、前端、preset、原生库与配置文件 → 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 将 `deepseek-harness-sdk-runtime--` 写入 `dist-exe/` 并拷回运行时目录。Linux CI 会在匹配的 manylinux 2.28 容器中重新构建 `pty.node`,因为 legacy deploy 会遗漏这一安装副作用。每个目标都会把对应的原生 `@vscode/ripgrep` 二进制复制到可执行文件旁,作为必需的 `-rg` 伴随文件;pkg 运行时通过 `process.pkg` 选择该伴随文件,普通 Node 执行则直接使用 `@vscode/ripgrep`。macOS 使用对应目标的预构建产物,并额外生成所需的 `-spawn-helper`。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 -CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[必需的 Python 运行时拉取请求验证](../testing/2026-08-12-required-python-runtime-pull-request-ci.md)调用它构建 linux-x64,手动派发 `workflow_dispatch` 或 PR(Pull Request)的 `build-exe` 标签可以显式选择构建目标,[公开发布工作流](../process/2026-08-11-python-publication-workflow.md)则调用它构建全部目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并在 manylinux 2.28 容器中运行;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 +CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml):[安装后 wheel Python 运行时拉取请求验证](../testing/2026-08-23-installed-python-wheel-black-box-ci.zh.md)与[公开发布工作流](../process/2026-08-11-python-publication-workflow.zh.md)都会调用它构建全部四个目标;`workflow_dispatch` 仍可选择部分目标。linux-x64、linux-arm64(`ubuntu-24.04-arm`)、macos-arm64 与 win-x64(`windows-2025`)分别进行原生构建,并在适用平台缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都把发布形态的 SDK wheel 包与运行时 wheel 包安装到 checkout 外的干净 venv,证明包与可执行文件来源,再通过公开 SDK 与直接 NDJSON JSON-RPC 运行完整 keyless 场景。可信拉取请求还会在每个目标上运行真实 DeepSeek 双轮工具冒烟测试;fork 与 Dependabot head 不会获得密钥。Linux 会检查可执行文件和原生 addon 各自的 GLIBC 依赖,并额外运行 manylinux 2.28 冒烟测试;macOS 则验证可执行文件的部署目标符合 wheel 包标签。完整构建四个目标时保留 5 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 4 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-v` 标签流水线,构建一个 SDK wheel 包和 4 个原生运行时 wheel 包,再由单个串行任务校验并将这 5 个文件发布到项目的 PyPI 注册表。[Windows x64 运行时决策](2026-08-23-python-sdk-windows-x64-runtime.zh.md)负责第四个目标及对 Windows arm64 的明确排除。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-sdk-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.zh.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含构建注入的平台可执行文件及其必需的 `-rg` 伴随文件和可选的 macOS helper,以及供仓库开发使用的构建注入 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 默认选择可执行文件;显式设置 `DSH_RUNTIME_MODE=node` 会在系统 Node 22.19 或更高版本上运行 `runtime/node/node_modules/@deepseek-ai/dsh/lib/bin.js`。node 载体从不进入 wheel 分发,两种载体都不使用检入的完整 `cordis.yml`。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 `-rg` 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`,或针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的 `X.Y.Z` 或预发布版本,把预发布版本转换为 PEP 440 写法,并以该 wheel 包版本暂存两个包,让 `deepseek-harness-sdk` 精确依赖匹配版本的 `deepseek-harness-runtime-bin`。可选的 `python-v` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。暂存过程还会把仓库许可证放入两个 wheel 包,并把第三方声明放入内置运行时 wheel 包。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe 及其架构匹配的 ripgrep 伴随文件,macOS wheel 包还包含与其架构匹配的 spawn helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64`、针对 Node 24 可执行文件 macOS 13.5 部署目标而保守选择的 `py3-none-macosx_14_0_arm64` 标签,或 `py3-none-win_amd64`;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、伴随文件缺失或多余,以及不支持的平台。 -exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-sdk-jsonrpc-server` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 +Python 客户端使用所选 profile(默认 `sdk`)、有序 patch 文件和显式 Harness home 启动打包后的 `dsh` 命令。Profile 负责 JSON-RPC 服务和应用组合;缺失 home、profile、bundle、patch 或 server 配置项都会失败,不存在外部完整配置回退。 ### 命名血统 -`@deepseek-ai/dsh-sdk-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 +`dsh-python-runtime-closure` 是私有部署 manifest,`deepseek-harness-sdk-runtime--` 是可执行文件族。协议字段 `serverInfo.name` 是 `deepseek-harness-sdk-runtime`;Python 分发包名是 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名是 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 @@ -64,7 +64,7 @@ exe 内支持 `dsh-workflow-worker-thread` 与 `dsh-code-runtime-worker-thread` ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都会把两个 wheel 包安装进 checkout 外的干净 venv,证明版本相同以及已安装模块/可执行文件的位置,再通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。文件系统搜索场景要求模型通过目标平台的 `-rg` 伴随文件调用 `glob` 与 `grep`。MCP 场景会启动临时外部 stdio server,刻意延迟首次 `tools/list` 响应,随后立即启动第一个 SDK 提示词;该提示词必须看到并调用已发现的工具,从而证明 `initialize` 是真正以 Loader 插件树完全稳定为准的就绪边界,而不是依赖定时 sleep。同一项安装后运行还会经 Python SDK 比较一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化 SDK 结果与通知流,以及父会话和两个子会话 JSONL 日志中不透明的消息、agent、工作流运行与会话 ID。可信拉取请求会增加真实提供方双轮文件写入/读取,并要求外部字节、工具调用、已完成原因与持久化日志一致。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,生命周期较短的管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml index 02aeec5ad6..7d8d2db1cf 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.md 2026-07-12-agent-scope-runtime-design.md: 9d70b8048b1d2bb50290d34158d9deb329d5e15e -2026-07-12-agent-scope-runtime-design.zh.md: a505f5337559294795c1080937c6079cee235baa +2026-07-12-agent-scope-runtime-design.zh.md: 278bcede47fee9f67d3d2d2d7135e5357c120161 diff --git a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md index a505f53375..278bcede47 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-agent-scope-runtime-design.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。 +[agent(智能体)作用域约定](2026-07-08-agent-scope-contexts.zh.md)对贡献者而言很简单:通过 `agent.ctx` 注册,解析出一个全局加单 agent 的视图,仅在 setup 完成后发布,并保持作用域直到工作停止。运行时必须在协作式插件框架、异步创建、可重入监听器、持久化会话提交以及 worker 或进程故障等场景下维护这份约定。 主要的设计风险是为每个竞态条件引入第二套机制。独立的预留、就绪哨兵、取消中继、快照层和保护注册表可能镜像同一个事实,直到没有读者能分辨哪个才是权威的。这些机制还会诱使运行时把可信的类型化调用当作敌对的序列化边界来处理。 @@ -30,7 +30,7 @@ Status: implemented 本 Agent Note 余下部分按依赖顺序展开这些选择:Cordis 机制、作用域路由、创建与会话提交、工具与提示词、subagent 与工作流,最后是可执行检查。 -[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md) 仍然是贡献者约定。独立的 [subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md) 拥有 `persona`、`toolFilter` 和 `maxDepth`;本文仅讨论它们的 setup 如何融入生命周期。 ## Cordis 模型:上下文、fiber、effect、receiver 与 waterfall @@ -58,6 +58,8 @@ Cordis 使用 dispatch receiver(`this`)过滤监听器,而 harness 的监 Cordis waterfall 是中间件风格的 dispatch。每个监听器接收 `next()`:调用它则委托给剩余监听器和基础操作,不调用则短路或替换下游结果。Waterfall 驱动提示词组装和工具策略;普通 emit 事件同步通知,parallel 事件等待所有监听器但没有否决结果。 + + ## 作用域路由:一个不透明键选择一层 scope 包实现了 Cordis 路由所需的最小对象。其载体仅持有一个组合的服务过滤器和作用域谓词,而包私有地记录不透明键,并单独暴露会等待作用域 fiber 完全停稳的 disposer。 @@ -72,7 +74,7 @@ Receiver 是一个小型载体而非领域对象的透明代理。需要 agent ### 注册表读取叠加一个精确 layer -作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.md))。 +作用域感知的注册表使用 `ScopedLayers`,拥有一个即时创建的全局 aggregate 和按标识键惰性创建的 aggregate。读取解析全局 layer 和至多一个精确局部 layer;它不创建状态,也从不遍历父级链。注册可见性与 Cordis effect 所有权都从同一个上下文派生,而回收会等待具体 layer 的完整 aggregate 变空(见[决策](2026-07-12-scoped-layers-store.zh.md))。 每个服务保留其领域规则。命名 command 和提示词视图使用共享的、保持插入顺序的 shadow 合并;工具保留更丰富的 resolver,因为限制会在加入局部工具前过滤全局工具,保留的 Code Mode transport 则单独插入。提示词变量和工具 guard 保持实时迭代,而工具提供方成员关系按每次 assembly 物化。Scope 提供存储生命周期和命名遮蔽,而非通用的注册表视图。 @@ -158,6 +160,8 @@ sequenceDiagram 此顺序让最终的 agent 和会话事件能使用匹配的作用域监听器,并使持久化观察者在最终刷新完成前保持附加。作用域 dispose 放在最后,因为注册撤销是外部可见的生命期边界。 + + ## 会话追加:物化、验证、提交、通知 会话事件跨越持久化边界,因此追加操作拥有其数据。算法的其余部分使用一条已附加的注册表条目和一个提交点。 @@ -208,7 +212,7 @@ Session 头部、种子和追加的事件是无损 JSON 数据。Session 构造 私有解析器应用当前展示模式、活跃的全局限制、精确的局部叠加和局部遮蔽。Schema、查找、执行、Code Mode SDK 生成和限制验证都使用该解析器或其限制前的全局名称视图。 -[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 +[subagent 组合控制 Agent Note](../feature/2026-07-12-subagent-persona-tool-filter-and-depth.zh.md#tool-filtering-is-one-live-global-view-rule) 拥有用户可见的 allow/deny 语义。实现要求是一致性:被过滤掉的全局工具不能通过另一条查找路径仍可执行,局部遮蔽的定义就是被展示和执行的同一个定义。 `ToolRestriction` 接受 readonly 的 allow/deny 名称并将其编译为内部集合。多个限制取交集。公开的 `visible()` 和 `knownNames()` 方法是不必要的,因为只有注册表需要中间视图。 @@ -230,6 +234,8 @@ SystemPrompt 首先将全局加 agent 的段、变量和工具提供方解析为 Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子级的精确作用域中,而 Code Mode 从同一个已解析的工具视图派生其传输和 SDK。第二套命名保护系统需要另一套所有权和碰撞规则来覆盖任意 schema 提供方(包括有意贡献重复名称的提供方),却不创建新的信任边界。 + + ### 结构化输出仅提交权威结果 结构化输出将子作用域组合与两阶段执行提交相结合。子级在发布前注册其 `structured_output` 工具和指令;可信的 assembly 监听器可以变换这些普通贡献,并有责任在期望子级完成时保持协议。工具体验证候选值并按当前 `ToolExecution` 暂存,但成功捕获仅由不可变的 `tools/result` 观察决定。 @@ -242,6 +248,8 @@ Scope 直接解决了真正的隔离问题。结构化输出贡献注册在子 纯 Code Mode 的注册表贡献从原生 wire schema 中省略 `structured_output`,并通过生成的 SDK 暴露它。Assembly waterfall 可以有意改变该展示;执行仍然针对子作用域定义进行验证,监听器拥有其创建的任何替代模型可见路由的一致性。 + + ### 三个执行边界有意设为单向 提示词组装有意是协作式的,但三个执行事实在其可扩展阶段之后需要单向结算: @@ -266,7 +274,7 @@ subagent 启动有一次所有权转移。提供方拥有未发布资源,直 ### 服务约定有一个取消通道 -`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.md)所要求的约定。 +`SubagentProvider.start()` 和 `SubagentRuntime.start()` 返回 `Promise`。Promise 会在后端跨过发布边界后兑现,因此调用方和 `subagent/start` 观察者从不需要第二个 `run.started` promise。提供方工作如果在发布前失败,`start()` 就会被拒绝;发布后的提示词、轮次、取消与基础设施结果会通过 `SubagentRun.result` 结算,且不会隐藏 child id,这也是[持久化目录决策](../feature/2026-07-22-durable-subagent-catalog-and-list-agents.zh.md)所要求的约定。 `SubagentStartRequest.signal` 是必需的。中止它会在启动期间,以及已发布 run 的剩余就绪或轮次工作中请求取消。`SubagentRun.dispose()` 也请求取消并等待完全停稳。没有单独的公开 `run.cancel()` 通道。 @@ -292,6 +300,8 @@ Start 仅在 `initialize` 和 `newSession` 成功后才 resolve。Abort、spawn Worker 和子进程桥接比同进程注册表需要更多状态,因为消息、进程死亡和清理可以独立结算。它们的状态围绕这些真实事实组织,而非重复的取消协议。 + + ### 工作流子级是待定 start 或已发布记录 工作流宿主保持待定的提供方 start promise 和已发布的子级记录。子级仅在异步 `SubagentRuntime.start()` 兑现时才从待定变为已发布;被拒绝的 start 清理其部分提供方工作且不产生子级生命周期对。 @@ -308,7 +318,7 @@ Worker 边界仍然序列化请求和结果。宿主保留首个终端结果仲 ### ACP 提示词结算不依赖更新投递 -[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。 +[仅面向自动化的 ACP 桥接层](../simplification/2026-07-23-acp-automation-only-protocol.zh.md)直接将一个进行中的提示词与其观察到的用户消息轮次关联。它不从日志水位线扫描,也不使用会话状态作为第二个调和预言机。 即使已提交消息的更新无法送达客户端,会话事件监听器也会从匹配的 `turn/end` 结算关联。因此更新投递不能让会话永久处于进行中状态。ACP 创建由服务器分配 id 的全新会话,并拥有由此产生的每个 agent 句柄,直到连接拆除。 @@ -330,13 +340,13 @@ TypeScript 无法管控 JavaScript 强制转换、直接 Cordis dispatch、进 ### 生成的产物使公开约定保持对齐 -事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 +事件目录、服务目录、生产者/消费方矩阵、配置目录、模块图、工具目录、type-equiv 块和作用域事件解析器映射都是从源码生成或受新鲜度门禁约束的。[TypeScript 语义门禁 Agent Note](../process/2026-07-14-typescript-program-backed-semantic-gates.zh.md) 拥有 Program 构造、语义事件发现和解析器生成规则。 行为测试固定了作用域路由和 dispose、最终写入注册表时的碰撞清理、发布回滚、有序完全停稳、持久化前/后提交行为、跨展示和执行的活跃工具过滤、协作式提示词组装、原生和 Code Mode 中的结构化输出提交、异步 subagent 启动和信号取消、worker 终端仲裁、ACP 结算和进程拆除。 ## 曾考虑的替代方案 -[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。 +[7 月 8 日 Agent Note](2026-07-08-agent-scope-contexts.zh.md#alternatives-considered) 拥有公开扁平作用域约定的替代方案。此处的替代方案关注实现形态。 ### 使用透明代理作为作用域载体 @@ -389,4 +399,4 @@ Worker 消息、进程死亡和持久化输入确实跨越所有权和序列化 该设计信任同进程中的类型化插件。它不防御任意强制转换、有状态 getter、违反 readonly 约定的修改,或插件有意在支持的组合 API 之外使用环境服务访问。 -[安全与权限非目标](2026-07-08-agent-scope-contexts.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 +[安全与权限非目标](2026-07-08-agent-scope-contexts.zh.md#security-and-authority-are-non-goals)仍然是根本性的。这些机制证明注册组合、发布和生命期所有权;它们不证明隔离或父到子的非升权。 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml index 86871f2bcd..205c3c4e39 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.md 2026-07-12-scoped-layers-store.md: 221711748007d5f909aec3fdfcf3919884d11cdb -2026-07-12-scoped-layers-store.zh.md: 7f3ad11e23f02eeafe955efe08525bda8a9e9996 +2026-07-12-scoped-layers-store.zh.md: 97ad637b91a2b9b2cad9847d8b4cdbe23ff14a48 diff --git a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md index 7f3ad11e23..97ad637b91 100644 --- a/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-12-scoped-layers-store.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。 +agent(智能体)作用域机制([决策](2026-07-08-agent-scope-contexts.zh.md)、[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md))让支持作用域的注册表反复呈现同一种形态:一个全局注册层,加上一个与具体 agent 精确对应的层。七个注册门面都采用这一形态:`tools.register`、`tools.restrict` 和 `tools.guard`(位于 `dsh-tools`);`SystemPrompt.section`、`SystemPrompt.tools` 和 `SystemPrompt.variable`(位于 `dsh-system-prompt`);以及 `CommandRuntime.register`(位于 `dsh-commands`)。 如果没有共享原语,每个门面都要围绕自己的领域状态重复相同的生命周期编排:从调用方上下文导出可见性,按需创建专属容器,把属主绑定到同一个 Cordis fiber,先装入 undo 再通知观察者,原样返回 Cordis 的 disposer,并回收空的专属状态。各自分离的映射与集合类型也会让服务缺少一个表示某个 scope 完整贡献的对象。 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 3f4683f480..34523f2723 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-14-provider-routed-llm-adapters.md 2026-07-14-provider-routed-llm-adapters.md: 78c8d6788006c503b532ff2bbddd30342415f0a4 -2026-07-14-provider-routed-llm-adapters.zh.md: 5e73cab5f1f2b1296c9a486d1c833e95bb5674a0 +2026-07-14-provider-routed-llm-adapters.zh.md: af8bc4fe27a50d47d7b49b51eada67afe889fc13 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 5e73cab5f1..af8bc4fe27 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 @@ -20,7 +20,7 @@ Status: implemented `GenerateOptions` 与 `LlmCallConfig` 在 `model: string` 之外携带 `provider: string`,`AgentOptions` 则携带对应的可选创建字段。只有两个值都非空时,agent loop(智能体循环)请求才有效;两个值也都会写入请求头日志。`agent/request` 可以在任意步骤返回替换后的字段组合,因此会话可以切换提供方与模型,无需改变 Cordis 插件生命周期。 -`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 +`LlmRuntime` 按提供方注册和解析适配器。`registerAdapter(providers, adapter)` 在修改注册表前检查整个提供方列表,遇到重复项时返回 `DUPLICATE_ADAPTER`,并以一个 effect 为单位整体 dispose(资源释放)。模型 ID 不作为注册键;仍由选中的适配器负责验证或转发。后续的 [LLM 目录与 ACP 模型选择 Agent Note](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) 增加了建议性的 `listProviders()` / `listModels()` 发现接口,但不会把目录成员关系变成请求校验规则。 在一个 Cordis 上下文中,一个提供方只能有一个适配器所有者。`dsh-llm-deepseek` 注册 `deepseek`;`dsh-llm-pi-ai` 也可以注册 `deepseek`,但同时加载两个所有者属于配置错误,不采用顺序规则或回退行为。若部署选择手写的 DeepSeek 实现,需从 pi-ai 配置中排除 `deepseek`;若部署选择 pi-ai 的 DeepSeek 实现,则不挂载 `dsh-llm-deepseek`。 @@ -40,11 +40,11 @@ pi-ai 的通用流选项不支持停止序列。若 Harness `stop` 选项已定 助手消息携带请求的 `provider` 和 `model`,以及可选的 JSON 可序列化适配器回放状态。成功的 `assistant/message` 会话事件记录这些字段,`deriveMessages()` 返回助手消息时也会包含它们。用户、系统、上下文与工具结果消息不携带助手路由字段。提供方/模型字段是 agent loop 的权威数据;适配器仅拥有其不透明回放状态 payload。 -成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 +成功的终止 `finish` 分片可以以 `ReplayEnvelope` 形式携带回放状态:不透明的响应级元数据,加上与发射块序列对齐的可选逐块条目。`BlockAssembler` 对内容与元数据只做一次保留/丢弃决定——max-token 组装丢弃工具调用时,数据同一位置的条目一并丢弃——因此 agent loop 附加到已组装助手消息模型来源中的状态始终描述存储的块,见 [max-token 回放状态对齐决定](../bug-fix/2026-08-15-max-token-replay-state-alignment.zh.md)。agent loop 不公开响应改写钩子。错误或中止响应不会生成正常助手消息,因此不会进入后续模型历史。 pi-ai 回放状态用其成功 `AssistantMessage` 的带版本最小投影填充该结构:一个响应半区(源 API/提供方/模型、响应 ID/模型、停止原因),以及逐块的文本签名、thinking 签名和工具调用签名。它不会重复 Harness 内容块中已有的文本或工具参数,也不包含诊断信息、时间戳、用量或错误。后续请求中,只有历史提供方和目标提供方当前归同一个适配器实例所有时,`LlmRuntime` 才会把回放状态交给目标适配器。适配器在能够恢复历史响应时,将 Harness 记录的内容与回放状态组合,并负责所需的跨模型或跨提供方转换。持久化内容保持权威:适配器收到无法使用的回放状态——未知 kind 或版本、格式错误的元数据、或与内容不再匹配的块结构——会把该消息降级为提供方无关转换并带出诊断;其他适配器只能收到提供方无关的内容以及提供方/模型字段。 -该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 +该状态属于模型可见的回放输入,因此遵循现有的[请求可重建规则](2026-07-05-reconstructable-requests.zh.md):它同时存在于终止 `finish` 分片和驱动派生的已组装 `assistant/message` 模型来源中。恢复和 fork 会原样保留该状态。压缩(compaction)遮蔽助手消息时,也会从活动 surface 中移除其回放状态;摘要属于普通的提供方无关内容。 ### 在所有请求生产方中传播目标 diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml index 458999f905..570b68263c 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.md 2026-07-15-agent-initiator-scope.md: 63540c0ec6b29a10613e01f1ed9ced24e8f2d277 -2026-07-15-agent-initiator-scope.zh.md: a0e3638081d875adc2412191829ab84c8dcd697c +2026-07-15-agent-initiator-scope.zh.md: 3ea893aa5f6992bf09965436c1db3144d2fae5ac diff --git a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md index a0e3638081..3ea893aa5f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-agent-initiator-scope.zh.md @@ -12,7 +12,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 ## 决策 -必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.md#initiating-agent)标明了所携带的类型。 +必需的 `ctx.agents` 服务使用 Node `AsyncLocalStorage` 携带发起 Agent。它直接存储同一个 `Agent`,不引入只有一个字段的帧;另一个私有运行标记只记录嵌套边界的谱系,供 teardown 记账使用,不携带身份。[核心数据目录](../../../../docs/subsystems/core.zh.md#initiating-agent)标明了所携带的类型。 `currentInitiator()` 用于可选读取,`requireInitiator()` 抛出 `no initiating agent is active`,`withInitiator(agent, operation)` 保留操作返回的同步值或 Promise 本身。`withoutInitiator(operation)` 会建立清空边界,供不得继承 Agent 的工作使用。会话仍通过 `agent.session` 推导;轮次、步骤、工具调用、`signal`、模型、`cwd`、沙箱和授权继续由现有归属方管理。 @@ -28,7 +28,7 @@ harness 中存在两种有用但不同的上下文概念。Cordis `Context` 负 宿主感知的传输层可以从 `ctx.agents.requireInitiator().session.id` 推导由部署方拥有的 `X-Harness-Session-Id` 等请求头;模型可见 schema 和参数中不包含该请求头。本决策不让现有生产 MCP 或 Web 传输层采用此请求头。测试替身传输层用于证明可信边界,而不会把宿主路由策略分配给现有的提供方无关 seam。 -本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.md),不会改变其中 `agent.ctx` 的静态含义。 +本决策扩展 [Agent 注册作用域约定](2026-07-08-agent-scope-contexts.zh.md)及其[运行时设计](2026-07-12-agent-scope-runtime-design.zh.md),不会改变其中 `agent.ctx` 的静态含义。 ## 验证 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 69af08605c..eb7a9adfab 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 .agents/notes/implemented/architecture/2026-07-15-llm-model-catalog-and-acp-selection.md -2026-07-15-llm-model-catalog-and-acp-selection.md: 3dddbe7e9fae74e4ce1ec1c8e93a40c3352b5a54 -2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 145fd0bc379ff8132d09ec628b6381a18f755cfa +2026-07-15-llm-model-catalog-and-acp-selection.md: fef23711a9214eae414809833bcd9ee9e26105ae +2026-07-15-llm-model-catalog-and-acp-selection.zh.md: 85e9fa35ac99b72a7df207fdb4971b57cf6dc525 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 3dddbe7e9f..fef23711a9 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 @@ -4,7 +4,7 @@ Status: implemented English | [中文](2026-07-15-llm-model-catalog-and-acp-selection.zh.md) -> The catalog decision remains current. Per-session ACP model selection is superseded by [ACP as an automation-only protocol](../simplification/2026-07-23-acp-automation-only-protocol.md). +> The catalog and scoped-selection decisions remain current. The temporary removal of ACP selection is superseded by [standard ACP v1 automation controls](../feature/2026-08-22-standard-acp-automation-controls.md), which exposes the catalog through standard session configuration without restoring UI projections. ## Problem @@ -24,17 +24,17 @@ 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` 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. +`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`, `deepseek-v4-pro` named `DeepSeek-V4-Pro`, and image-capable `deepseek-v4-flash-vision-exp` named `DeepSeek-V4-Flash-Vision-Exp`. 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 end -A selection is owned by the front end that offers it (today the TUI `/model` selector), never by `LlmRuntime` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. +A selection is owned by the front end that offers it, never by `LlmRuntime` or `AgentOptions`: those are deployment-wide or creation-wide objects, and mutating them would couple concurrent sessions. Each opaque choice carries the full provider/model pair, because the same model id may appear under multiple routes. -The ACP automation transport is not a catalog consumer. Its deployment config supplies one optional provider/model target for newly created agents, and it advertises no model selector or configuration-option interface. +The ACP automation transport consumes the advisory catalog through standard session configuration options. Its deployment config still supplies the initial provider/model target; each session owns an opaque provider/model choice and a dependent exact-model reasoning-effort choice. Adapter topology changes publish the complete option state. Catalog absence never invalidates the configured route: the current unlisted route is synthesized into the choices. ### Prompt/request consistency and durability -`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Prompt assembly snapshots the selected pair once per step, overwrites the assembled `provider` and `model` variables after downstream prompt listeners, and the request listener applies that same snapshot after downstream request listeners. A selection during asynchronous assembly therefore starts on the next step rather than splitting prompt text from routing. Other call-config fields remain untouched. +`installModelSelection` (in `dsh-agent`) installs scoped `system-prompt/assemble` and `agent/request` listeners for a front-end-owned selection. Ordinary consumers snapshot the selection once per step. ACP associates its admission snapshot with the identified message in the per-session module until inbox claim, then pins that selection for the complete admitted turn, so asynchronous image admission, prompt variables, and every request step remain aligned without changing the durable user source. A concurrent selection starts on the next ACP turn. Other call-config fields remain untouched. The request header remains the durable source of truth. When a selection is actually used, the existing full `request/header` snapshot records it, and a front end initializes its selection from the folded last request header before falling back to creation options. A selection that is never used by a request is intentionally in-memory only because it never became model-visible state. @@ -53,10 +53,10 @@ The request header remains the durable source of truth. When a selection is actu - Any adapter can expose a dynamic model list without leaking provider-library types into the LLM Service Definition. - Catalog consumers must treat absence as “not advertised,” never “invalid request.” - pi-ai adapters expose their installed provider catalogs; hand-written DeepSeek deployments list known choices explicitly and retain arbitrary model support. -- Human-facing catalog consumers own their selection interaction. ACP uses its fixed deployment target and does not widen the protocol with model discovery. +- Each catalog consumer owns its selection interaction. ACP uses standard session configuration options and emits no DSH-specific selector or UI metadata. - Request headers remain compatible with the provider-routed session shape; no new JSONL event or format version is required. - A catalog read can be asynchronous, and every caller receives detached values. ## Testing -Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP transport tests validate fixed provider/model forwarding independently of catalog discovery; the TUI suite covers selector interaction and header-based restoration. +Unit coverage validates catalog detachment and malformed metadata, pi-ai and DeepSeek catalog projection, provider/model request routing, and prompt-variable alignment; per-agent isolation follows from installing the listeners on the agent-scoped context. ACP tests validate grouped discovery, invalid and concurrent changes, topology updates, header-based restoration, per-turn route pinning, and image-route consistency; human clients test their own selector presentation. 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 145fd0bc37..85e9fa35ac 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 @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-15-llm-model-catalog-and-acp-selection.md) | 中文 -> 目录决策仍然有效。ACP(Agent Client Protocol)会话级模型选择已由 [ACP 作为仅面向自动化的协议](../simplification/2026-07-23-acp-automation-only-protocol.md)取代。 +> Catalog 和 scoped selection 决策仍然有效。ACP selection 的暂时移除已由[标准 ACP v1 自动化控制](../feature/2026-08-22-standard-acp-automation-controls.zh.md)取代;后者通过标准会话配置公开 catalog,但不会恢复 UI 投影。 ## 问题 @@ -24,17 +24,17 @@ ACP 选择还必须保留提供方维度。同一个模型 ID 可能存在于多 目录成员关系仅提供建议。它驱动选择器与诊断,但不会改变 `stream()` 路由,也不会拒绝原本有效的请求。提供方所有权仍然具有排他性并绑定生命周期;模型 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 仍会原样透传。 +`dsh-llm-pi-ai` 将已配置提供方的 `getModels(provider)` 返回的已安装条目映射为提供方无关的目录。其现有请求时目录查询仍是权威依据,未知模型仍以 `UNKNOWN_MODEL` 失败。`dsh-llm-deepseek` 接受包含展示条目的可选 `models` 配置,默认包含名为 `DeepSeek-V4-Flash` 的 `deepseek-v4-flash`、名为 `DeepSeek-V4-Pro` 的 `deepseek-v4-pro`,以及名为 `DeepSeek-V4-Flash-Vision-Exp`、支持图片输入的 `deepseek-v4-flash-vision-exp`。显式列表会替换这些默认值,空列表则关闭发现。这些条目改善已知公开或私有模型的选择体验,而所有未列出的模型 ID 仍会原样透传。 ### 前端内的会话级选择 -选择由提供它的前端拥有(今天是 TUI 的 `/model` 选择器),而不由 `LlmRuntime` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 +选择由提供它的前端拥有,而不由 `LlmRuntime` 或 `AgentOptions` 拥有:它们是部署级或创建级对象,改动它们会把并发会话耦合在一起。每个不透明选项都携带完整的提供方/模型对,因为同一模型 ID 可能出现在多个路由下。 -ACP 自动化传输层不是目录消费方。它通过部署配置为新创建的 agent 提供一个可选的提供方/模型目标,不展示模型选择器或配置选项接口。 +ACP 自动化传输层通过标准会话配置选项消费建议性 catalog。部署配置仍提供初始提供方/模型目标;每个会话拥有一个不透明的提供方/模型选择,以及一个依赖确切模型的 reasoning-effort 选择。Adapter 拓扑变化会公布完整选项状态。Catalog 中缺少条目不会使配置路由失效:当前未列出的路由会合成到选项中。 ### 提示词/请求一致性与持久化 -`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。提示词组装在每个步骤对所选组合做一次快照,在下游提示词监听器之后覆写组装出的 `provider` 与 `model` 变量;请求监听器在下游请求监听器之后应用同一快照。因此,发生在异步组装期间的选择会从下一个步骤生效,而不会让提示词文本与路由分裂。其他调用配置字段保持不变。 +`installModelSelection`(位于 `dsh-agent`)为前端拥有的选择安装 agent 作用域的 `system-prompt/assemble` 与 `agent/request` 监听器。普通 consumer 每个步骤快照一次选择。ACP 会在 per-session 模块中把准入快照与已识别消息关联到 inbox claim 时刻,再在完整已准入轮次中固定该选择,使异步图片准入、提示词变量和每个请求步骤保持一致,同时不改变持久用户 source。并发选择变更从下一个 ACP 轮次开始。其他调用配置字段保持不变。 请求头仍是持久化的真源。当某个选择真正被使用时,现有的完整 `request/header` 快照会记录它;前端先从折叠后的最后一个请求头初始化其选择,然后才回退到创建选项。从未被请求使用的选择有意只保留在内存中,因为它从未成为模型可见状态。 @@ -53,10 +53,10 @@ ACP 自动化传输层不是目录消费方。它通过部署配置为新创建 - 任意适配器都能暴露动态模型列表,无需把提供方库类型泄漏到 LLM Service Definition。 - 目录消费方必须把缺失理解为「未展示」,而不是「请求无效」。 - pi-ai 适配器会暴露其已安装的提供方目录;手写 DeepSeek 部署显式列出已知选项,同时保留对任意模型的支持。 -- 面向人类的目录消费方拥有各自的选择交互。ACP 使用固定部署目标,不会为模型发现扩大协议范围。 +- 每个 catalog consumer 拥有自己的选择交互。ACP 使用标准会话配置选项,不发出 DSH 专用 selector 或 UI 元数据。 - 请求头与基于提供方路由的会话形态保持兼容;不需要新的 JSONL 事件或格式版本。 - 目录读取可以是异步的,且每个调用方都会收到值的独立副本。 ## 测试 -单元测试覆盖目录值副本与格式错误的元数据、pi-ai 和 DeepSeek 目录投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 传输测试独立验证固定提供方/模型的转发行为;TUI 套件覆盖选择器交互与基于请求头的恢复。 +单元测试覆盖 catalog 值副本与格式错误的元数据、pi-ai 和 DeepSeek catalog 投影、提供方/模型请求路由,以及提示词变量对齐;监听器安装在 agent 作用域的上下文中,因此能够实现 agent 间隔离。ACP 测试覆盖分组发现、无效和并发变更、拓扑更新、基于请求 header 的恢复、逐轮路由固定以及图片路由一致性;人工客户端测试自己的 selector 展示。 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml index aee9c76d55..277e0b097d 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.md 2026-07-15-lsp-capability-seam.md: 90f9daf4b890bd53621916e492d4d82baaa3e8cc -2026-07-15-lsp-capability-seam.zh.md: b85b478b25a375b04e1301d893e53198554641e3 +2026-07-15-lsp-capability-seam.zh.md: bdb5e812e94a4aec4402fe83ca9c818bfa00f9f9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md index b85b478b25..bdb5e812e9 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-lsp-capability-seam.zh.md @@ -79,7 +79,7 @@ interface LspService { 映射键规范化为带前导点的小写扩展名,并按 `filePath` 的最后一个扩展名选择;语言 id 仅用于文档同步。seam 中的位置和范围从零开始按 UTF-16 计数。`findReferences` 始终包含声明:提供方在内部执行该约束,本地映射设置 `context.includeDeclaration: true`,调用方不能配置。封闭结果联合将导航统一为位置,将 `hover` 统一为内容或 `null`;导航结果携带提供方的规范工作区 URI,使消费方在执行世界的命名空间内相对化文件 URI。seam 不公开协议类型、进程或文档控制,也不提供通用请求逃生口。 -`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 +`dsh-lsp-stdio` 负责服务器配置、JSON-RPC、进程与临时文档状态和协议转换。它通过 `ctx.fs` 读取,通过 `ctx.subprocess` 启动,只依赖二者的 Service Definition 包而非具体提供方;[可移植执行环境决策](2026-07-28-portable-execution-world-consumers.zh.md)负责定义这种配对。服务器表的键是提供方 id。插件在注册前解析每个服务器的本地设置;如果后续映射无效或发生冲突,插件会撤销此前的注册,并为每个提供方保留独立进程池。`dsh-tool-lsp` 在运行时只注入 `tools`、`lsp` 和 `systemPrompt`,通过包内的 `sessionCwd(exec)` 辅助函数从 `exec.agent?.session.header.cwd` 取得工作区,其取值方式与文件系统工具一致,也不导入提供方。 ## 面向模型的约定 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml index 47e7156ab8..6ad1773f6f 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.md 2026-07-15-replay-token-meter-service.md: 10261189fca621b305b3ec6347b54471d81c8ba3 -2026-07-15-replay-token-meter-service.zh.md: 4e460b3c32630f205f447a16205bd4c9b1d89faf +2026-07-15-replay-token-meter-service.zh.md: 370d3cf932d37702c5298bc79e9dc8f5a37835b9 diff --git a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md index 4e460b3c32..370d3cf932 100644 --- a/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-15-replay-token-meter-service.zh.md @@ -16,7 +16,7 @@ Status: implemented `@deepseek-ai/dsh-token-meter` 是 `packages/llm/` 下的单个具体包,并注册 `ctx.tokenMeter`。在第二种实现出现之前,它不会被拆成接口与后端。`TokenMeter` 本身公开 `measure(session, requestHeader?)` 与 `estimateMessage(message)`;消费方直接调用这个单例服务。 -服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.md)。 +服务没有配置。估算采用固定的每 token 四个字符启发式规则,并加上结构开销。服务不提供模型 profile、容量设置、密度设置、分词器后端或语言专用策略。对精确提供方/模型容量的查询由适配器单独负责,具体见[路由模型上下文与压缩策略 Agent Note](2026-07-20-routed-model-context-and-compaction-policy.zh.md)。 ### 逐会话回放折叠 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 342f2073ea..fcca895513 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 @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-16-explicit-turn-cancellation.md 2026-07-16-explicit-turn-cancellation.md: 86faad9929d3eb5b00e66bb1c46a2e35b135d954 -2026-07-16-explicit-turn-cancellation.zh.md: 5397b368628c196efc2b35c00855267246ac6e85 +2026-07-16-explicit-turn-cancellation.zh.md: acf56e0629668a227324045ffd0521619dc45f33 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 5397b36862..acf56e0629 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 @@ -8,7 +8,7 @@ Status: implemented 取消是一种生命周期短于 Agent(智能体)驱动器的控制能力。自由文本字符串无法完整区分所有调用方,步骤级控制器也无法中断提示词提交、提示词组装、继续决策或轮次终止策略。持久化 `Error`、`AbortSignal.reason` 或后端私有对象还会向持久化回放暴露不稳定的运行时细节。 -[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 +[发起 Agent 作用域决策](2026-07-15-agent-initiator-scope.zh.md)有意让 AsyncLocalStorage 只携带同一个 Agent。若把轮次、步骤或 signal 状态加入这个与驱动器同生命周期的边界,陈旧的异步后代就会看似仍对后续轮次拥有权限。因此,取消需要一个轮次归属方并显式传播,且不创建另一套环境上下文或公开的轮次包装层。 ## 决策 @@ -18,9 +18,9 @@ Agent 拥有仅用于运行时的 `AgentCancelCause` 联合类型 `{ kind: 'user AgentLoop 为每个待启动轮次私有地持有一个 `TurnCancellation`。它在通知 `agent/status = running` 前安装该持有者,使其中唯一的 `AbortController` 持续覆盖 inbox 领取、`agent/pre-step`、提示词组装、每个步骤、模型与工具执行以及 `agent/turn-stopping`;随后在发布 `turn/end` 前立即清除所安装的那个持有者。因此,即使驱动器状态可能在持久化刷新结算前保持 `running`,终态事件观察者及其后的持久化刷新也无法取消已完成的轮次工作。所有参与的方法、事件和请求值都会收到同一个显式 signal;下一个轮次会收到全新的 signal。 -对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 +对于轮次被认领前已取消的排队工作,驱动器只保留一个不携带取消原因的运行前标记。实际生效的 `cancel()` 会先发出仅供观察的 `agent/cancel-requested` 通知并携带最终确定的类型化取消原因,然后才清除排队工作和 steering(中途引导)工作或中止持有者;通知失败不能阻止此次停止,空闲状态下调用则不发出任何通知。通知观察者同步加入队列的工作也会被这次清除,而稍后由 signal 中止观察者加入队列的工作会被锁存,并在被中止的活动收敛到空闲时执行——`disposed` 取消则将其停放([取消收敛窗口唤醒锁存](../bug-fix/2026-08-07-cancel-convergence-wake-latch.zh.md))。若 `running` 监听器同步取消旧工作并发送替代提示词,驱动器会丢弃已中止的持有者,并为替代提示词创建全新的持有者。同一活跃持有者上的重复取消遵循首次请求优先,后续调用仍可清除新入队的待处理工作。 -显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 +显式事件签名传递单个 payload 对象:agent 作用域事件在 payload 中携带 `agent` 和 `signal`,`next` 位于最后;其余 API 保持 `signal` 紧邻 waterfall(瀑布式事件)的最终 `next` 之前。`PreStepContext` 与 `RequestFailureContext` 已退役,其字段并入 `agent/pre-step` 与 `agent/request-error` 的 payload([payload-object 事件](2026-08-06-agent-event-payload-objects.zh.md))。进入 pre-step 时、请求配置、请求错误恢复、模型生成、工具执行、审批、轮次停止以及 subagent 或工作流请求都会收到当前 signal。钩子桥接器也必须提供 `RunHookOptions.signal`,使轮次取消能够到达 Bash 执行器终止进程组并等待其退出的边界。`SystemPrompt.assemble()` 在 `AssembleContext` 中携带 `signal?: AbortSignal`,因为该对象是显式请求值,也可表示轮次之外不携带 signal 的组装。监听器可以配合该 signal 取消,但不得保留它来控制其他轮次。 `ctx.agents` 仍只携带发起 Agent。环境中的 Agent 并不代表存活、当前轮次或取消权限。cause 读取器是 loop 私有的,它直接陈述机器私有的 slot 不变量(只有 `cancel()` 会中止轮次控制器,且总是携带规范的冻结 cause),而不是对 reason 做结构化再校验;不存在从任意 signal 读取 cause 的公开辅助函数。并发 Agent 会同时隔离各自的发起方身份和轮次 signal;子驱动会遮蔽父发起方,而父请求 signal 仍通过 subagent seam 传递。 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml new file mode 100644 index 0000000000..06f4d81cb0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md +2026-07-17-local-spill-startup-cleanup.md: fc64938c1af07d9dd0d7ecec379115d22d1e2464 +2026-07-17-local-spill-startup-cleanup.zh.md: 583a33ead84f552c67e2e770a8b3fabc3ce88120 diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md new file mode 100644 index 0000000000..fc64938c1a --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.md @@ -0,0 +1,37 @@ +# Agent Note: One-shot startup cleanup for local spill files + +Status: implemented + +English | [中文](2026-07-17-local-spill-startup-cleanup.zh.md) + +## Problem + +The local spill backend never deleted the full tool results it wrote. Every oversized result added another file, so configured roots grew without bound and default per-process `dsh-spill-*` roots accumulated across runs. Immediate deletion is wrong because persisted, resumed, and forked sessions may still reference a locator. The [tool output spill policy](./2026-07-08-tool-output-spill-files.md) needs a bounded local-storage lifetime. + +## Decision + +`dsh-spill-local` runs one best-effort cleanup sweep after activation. It does not delay service availability, is owned by the plugin fiber (a single `ctx.effect` whose generator launches the sweep and yields an async disposer that awaits it), and is awaited during disposal so no sweep I/O outlives the fiber. There is no recurring timer and no separate process. + +A `cleanupPeriodDays` config defaults to `30`; `0` disables cleanup. Schemastery rejects a negative or fractional value at load. The sweep scans the configured/active root plus any prior default `dsh-spill-*` temp roots discovered under the OS temp dir and deletes regular files whose `mtime` is strictly older than `now − cleanupPeriodDays`. It prunes every empty session directory but removes the root itself only for a discovered prior-default root; writes recreate a session directory if pruning races them. Root aliases are de-duplicated by device/inode identity, with the configured identity overriding a discovered match as active and non-prunable. It uses `lstat`, so a symlink is never followed or deleted; unrelated entries (non-`session-` directories, special files) are skipped. Every filesystem failure is caught and logged through `ctx.logger.warn`, and a warning-sink exception is also contained — the sweep never throws, so it cannot reject activation or a concurrent spill write. + +Path-based deletion is restricted to directories an untrusted local OS user cannot replace during the scan. On POSIX, every root and session directory must be owned by the current user and not writable by group or others; the root's ancestor path must also be non-writable or protected by a sticky directory such as `/tmp`. Discovery rejects symlinks, while a configured symlink may resolve to a trusted target and participates in identity de-duplication. An unsafe path is skipped with a warning. The same-user account remains the trust boundary, consistent with the backend's private local-storage model. + +The ctx-free sweep mechanics live in `packages/spill/spill-local/src/cleanup.ts` (`sweepSpillRoots`, `discoverDefaultRoots`), unit-testable without a `ctx`; `store.ts` owns root naming, path derivation, and writes, while the service in `src/index.ts` owns the config, cutoff, and fiber-owned launch/await. + +## Alternatives considered + +**Run a periodic timer.** Rejected because it adds timer lifecycle, overlap control, and another interval knob. A long-lived process may retain files until restart. + +**Delete spills on session disposal.** Rejected because durable sessions, resumes, and forks retain locators. + +**Delete old session directories recursively.** Rejected because a concurrent process may create a fresh spill after the age check. Per-file expiry preserves fresh writes. + +**Tie cleanup to session-persistence deletion.** Rejected because the persistence seam has no common deletion lifecycle, while the local backend also owns independent temporary roots. + +## Consequences + +Cleanup cost the backend a startup sweep and a config knob, and bought a bounded local-storage lifetime without a timer, a daemon, or a session-lifecycle coupling. Concurrent processes may duplicate startup I/O; strict filtering and idempotent file deletion keep this safe. A long-lived process is not cleaned again until restart, and retention deliberately makes old model-visible locators stale only once they age past the cutoff. The seam itself still defines no retention policy — this is a local-backend concern. + +## Testing + +`dsh-spill-local` unit tests cover the exact age boundary, `cleanupPeriodDays: 0` disabling, empty-session and discovered-root pruning, symlink/unrelated-entry skipping, configured-plus-discovered-root coverage, filesystem-identity de-duplication through a configured symlink, unsafe POSIX root/session rejection, load-time config validation, filesystem- and warning-sink-failure containment, and the quiescence contract. A separate test boots the plugin through the real Loader and a cordis.yml, then observes configured expiry and directory pruning after disposal. diff --git a/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md new file mode 100644 index 0000000000..583a33ead8 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-17-local-spill-startup-cleanup.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 本地 spill 文件的一次性启动清理 + +Status: implemented + +[English](2026-07-17-local-spill-startup-cleanup.md) | 中文 + +## 问题 + +本地 spill 后端从不删除它写下的完整工具结果。每个超限结果都会新增一个文件,因此配置的根目录会无限增长,而每进程默认的 `dsh-spill-*` 根目录也会跨多次运行不断累积。立即删除是错误的,因为已持久化、已恢复和已 fork 的会话仍可能引用某个 locator。[工具输出 spill 策略](./2026-07-08-tool-output-spill-files.zh.md)需要一个有界的本地存储生命周期。 + +## 决策 + +`dsh-spill-local` 在激活后运行一次尽力而为的清理扫描。它不延迟服务可用性,由插件 fiber 拥有(一个 `ctx.effect`,其生成器启动该扫描并让出一个等待它的异步 disposer),并在 dispose 期间被等待,因此没有扫描 I/O 会存活到 fiber 之后。既没有周期性定时器,也没有独立进程。 + +`cleanupPeriodDays` 配置默认为 `30`;`0` 会禁用清理。Schemastery 会在加载时拒绝负数或小数。扫描会遍历配置的/活动的根目录,以及在 OS 临时目录下发现的任何先前默认 `dsh-spill-*` 临时根目录,并删除 `mtime` 严格早于 `now − cleanupPeriodDays` 的常规文件。它会修剪所有空会话目录,但只删除发现的先前默认根目录本身;如果修剪与写入发生竞争,写入操作会重新创建会话目录。根目录别名按设备/inode 身份去重,配置目录的身份会覆盖发现的匹配项,并标记为活动且不可删除。扫描使用 `lstat`,因此符号链接绝不会被跟随或删除;无关条目(非 `session-` 目录、特殊文件)会被跳过。每一次文件系统失败都会被捕获并通过 `ctx.logger.warn` 记录,警告接收方抛出的异常也会被兜底——扫描绝不抛出,因此它无法让激活失败,也无法影响并发的 spill 写入。 + +基于路径的删除仅限于不受信任的本地 OS 用户无法在扫描期间替换的目录。在 POSIX 上,每个根目录和会话目录都必须由当前用户拥有,且组用户和其他用户不可写;根目录的祖先路径也必须不可写,或由 `/tmp` 这类 sticky 目录保护。发现过程拒绝符号链接,而配置的符号链接可以解析到可信目标并参与身份去重。不安全路径会被跳过并记录警告。与后端的私有本地存储模型一致,同一用户账号仍是信任边界。 + +无 ctx 依赖的扫描机制位于 `packages/spill/spill-local/src/cleanup.ts`(`sweepSpillRoots`、`discoverDefaultRoots`),无需 `ctx` 即可做单元测试;`store.ts` 负责根目录命名、路径推导与写入,而 `src/index.ts` 中的服务负责配置、截止时间以及 fiber 拥有的启动/等待。 + +## 考虑过的替代方案 + +**运行周期性定时器。** 已否决,因为它引入了定时器生命周期、重叠控制以及又一个间隔旋钮。长期运行的进程可能会保留文件直到重启。 + +**在会话 dispose 时删除 spill。** 已否决,因为持久会话、恢复和 fork 都会保留 locator。 + +**递归删除旧的会话目录。** 已否决,因为并发进程可能在年龄检查之后创建一个新的 spill。按文件过期可保留新写入。 + +**将清理绑定到会话持久化删除。** 已否决,因为持久化 seam 没有共同的删除生命周期,而本地后端还独立拥有临时根目录。 + +## 后果 + +清理让后端付出了一次启动扫描和一个配置旋钮的代价,换来了无需定时器、守护进程或会话生命周期耦合的有界本地存储生命周期。并发进程可能重复启动 I/O;严格的过滤与幂等的文件删除保证了这一点的安全。长期运行的进程在重启前不会再次被清理,而这种保留是刻意的——旧的模型可见 locator 只有在超过截止时间后才会失效。seam 本身仍不定义任何保留策略——这是本地后端的关切。 + +## 验证 + +`dsh-spill-local` 单元测试覆盖了精确年龄边界、`cleanupPeriodDays: 0` 的禁用、空会话目录与发现根目录的修剪、符号链接/无关条目的跳过、配置根加发现根的覆盖、经配置符号链接验证的文件系统身份去重、不安全 POSIX 根目录/会话目录拒绝、加载期配置校验、文件系统与警告接收方故障兜底,以及静止契约。另一个测试会通过真实 Loader 和 cordis.yml 启动插件,并在 dispose 后观察按配置执行的过期与目录修剪。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml index e772ff9036..29807cfb7c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.md 2026-07-19-cooperative-tool-cancellation.md: 781202688a5cbcd7076ee694fc7dd9489683d8e8 -2026-07-19-cooperative-tool-cancellation.zh.md: a5ce2e671e92f4758ddeb3d3556d8af574467f7f +2026-07-19-cooperative-tool-cancellation.zh.md: ec35734eef91c5c774d1be814b221e9fdb8f65fa diff --git a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md index a5ce2e671e..ec35734eef 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-cooperative-tool-cancellation.zh.md @@ -42,7 +42,7 @@ Status: implemented 工具主体一旦启动,注册表就会等待它完成。取消通过融合信号到达工具主体,但注册表不会与其 promise 竞速或丢弃该 promise。协作式实现会停止自身工作或继续转发取消,并在所持有的工作完全停稳后完成;不协作的同进程实现可能让注册表无限期保持等待。进程、worker、网络和提供方层仍负责各自的终止机制。 -这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.md)。 +这项决策只要求工具调用边界携带取消信号。让工具主体可达的异步能力也必须接收信号,属于另一项迁移,见提议中的[工具可达能力 seam 中的必填取消](../../proposed/architecture/2026-07-19-required-cancellation-through-tool-capability-seams.zh.md)。 ## 验证 @@ -62,7 +62,7 @@ Status: implemented **禁止环绕包装层替换信号。** 不予采纳,因为截止时间和嵌套操作作用域需要词法派生信号。捕获并融合调用方信号既保留组合能力,也不允许切断调用方取消。 -**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.md#dispose-must-reach-quiescence-not-just-request-it)。 +**让工具 promise 与取消竞速。** 不予采纳,因为这种方式会在副作用仍可能存活时报告完成,违反[dispose(资源释放)必须完全停稳的规则](../../../../docs/defensive-patterns.zh.md#dispose-must-reach-quiescence-not-just-request-it)。 ## 后果 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index b864b41f76..38e07804b7 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 620803668e88f5a462ab2a75e6e916a85d433ed6 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: 8145b6e5af149f79b45e03c80aeea90da90b3729 +2026-07-19-gui-layering-and-rpc-protocol.md: 372bf4926011835999ebae9b1e2d1f5beb8eb663 +2026-07-19-gui-layering-and-rpc-protocol.zh.md: ecce57c01c155c2a0b19b7729da13c39d1a520a6 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 620803668e..372bf49260 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -209,7 +209,7 @@ The same domain tree as `ApiProxy`, but unary methods **take the business payloa ### The instance-level envelope observation aspect -All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes today — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier). +All four quadrant full forms pass through `onEnvelope`; the base implementation is an **instance-owned microtask-batched buffer** (frame storms must not disturb consumers per frame; module-level state would leak across instances/tests, hence instance-owned). Observers subscribe via `subscribeEnvelopes(listener)` (receiving whole batches as `readonly RpcMessage[]`, returning an unsubscribe function); a listener throw is isolated (observation must never bite the carrier). With no subscribers the buffering costs nothing. No shipped consumer subscribes — the aspect is the designated seat for wire diagnostics (the retired RPC debug panel was its first consumer, and a future one plugs in without touching the carrier). ### The subclass table (transport carriage) diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index 8145b6e5af..ecce57c01c 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-layering-and-rpc-protocol.md) | 中文 -> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.md)。 +> 分工线:本篇 = 分层模型 + 通道无关的 RPC 协议;协议的 Web 实现由 HTTP 上行加 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)组成,浏览器对象层见 [Web 客户端架构笔记](2026-07-19-gui-web-client-architecture.zh.md)。 ## Problem @@ -23,13 +23,13 @@ Status: implemented 目录按照如下分层: - `packages/host/*`:包只提供 Host 侧能力(代表了以现在 Harness 实体插件系统为主体的 Node.js 代码核心工程),除此之外,还包含 - 统一后端协议(fetch、HTTP、流式接口等)定义和支持,见本篇「消息协议」起各节 -- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有): +- `packages/client/*`:包只提供 Client 侧能力,每包单边不混。这里住三类包(两条轴归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有): - **纯库**(`ui-slots`、`ui-primitives`,外加内核包 `loader`):普通根入口包,静态打包进壳;两个客户端库播种进模块表。 - **静态到达 entry 包**(`connection`、`runtime`、`ui-theme`、`i18n`、`hmr`):无 `dsh.client` 键、无浏览器 bundle——壳把它们的 `src/client/` 半边打进自己的 bundle 并向 `ctx.modules` 登记;它们与其余单元一样,作为 host 独家撰写的图里的 entry 受治理。 - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-web-frontend`)是 vite 应用:`dsh-client-web` 导出的壳 API 之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-web-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.zh.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -50,9 +50,9 @@ harness core packages ──────────────────┘ - `runtime → apiproxy` 单向;apiproxy 仅依赖类型定义。 - client 侧包**永不 import** host 侧包的运行时(只吃 `/api`、`/client` 两个浏览器安全子路径)。 - `webserver` 不依赖 `runtime`:它提供 `{ fetch }` 特定实现 ——「webserver ← runtime」只是运行时注入关系,不是包依赖。 -- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有)。 +- client 侧跨包 import 插件包一律走 `/client` 子路径,且插件包之间只限类型 import——跨插件值 import 在 tsdown 纯度门禁处即构建错误(值层面的协作走 cordis 服务;边规则归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.zh.md) 所有)。 -TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.md))。 +TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig.json` = solution;`tsconfig.host.json` = host 侧 + 测试,排除 `packages/client`;`tsconfig.client.json` = client 各包及其测试):两侧在相同键(`sessions`、`loader`)下以不同服务合并 cordis `Context` 接口,单一 program 会同时看到两份声明合并而报冲突。共享叶子包(session/llm/tools/apiproxy 等)只构建一次,由两个 program 共同引用([拓扑](../process/2026-07-22-tsconfig-solution-root-two-aggregates.zh.md))。 协议侧:TS interface(`packages/host/apiproxy/src/api/`,零 Node 依赖,浏览器可 import);wire 消息统一为**双向模型**——每条逻辑消息按「谁发起 × request/response」分类(两轴四格,后文称四象限),与物理通道解耦;客户端统一继承 `AbstractApiClient`(协议不变量全在基类,平台差异只是 `doFetch` 传输切面)。 @@ -167,7 +167,7 @@ export type ResponseValue = ### 帧(server→client,具名 union) -两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md)。帧示例一行: +两条逻辑流:mux 流(`/api/events.mux`,全 session 聚合)与 host 流(`/api/events.host`,host 级事件)。浏览器通过每流一条下行 WebSocket 消费,进程内 fetch 载体以 SSE 保持同构;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md)。帧示例一行: | 帧 type | 载荷 | 何时发 | |---|---|---| @@ -207,14 +207,14 @@ export type ResponseValue = ### 实例级 envelope 观测切面 -四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费方;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。当前没有任何现役消费方订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费方,将来的诊断消费方接入时不动载体)。 +四象限全形均过 `onEnvelope`;基类实现是**实例持有的微任务合批缓冲**(帧风暴不逐帧惊扰消费方;模块级状态会跨实例/测试泄漏,故实例持有)。观测者经 `subscribeEnvelopes(listener)` 订阅(收整批 `readonly RpcMessage[]`,返回退订函数);listener 抛异常被隔离(观测不得反噬载体)。无订阅者时零缓冲成本。没有任何已交付消费方订阅——该切面是 wire 诊断的预留位(已退役的 RPC 调试面板是它的首个消费方,将来的诊断消费方接入时不动载体)。 ### 子类表(传输承载) | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| | `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | -| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | +| `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.zh.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | 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 2d5aaa3a25..b80381f031 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 .agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md -2026-07-19-gui-web-client-architecture.md: 8b4f940299cbba78d403c34b1e5fc9740e44f2c2 -2026-07-19-gui-web-client-architecture.zh.md: a86f9d6f71bed0ce147ffee39bd921037d6c1c4c +2026-07-19-gui-web-client-architecture.md: 4448fd5c6871d67b30b71cfe4377682639704235 +2026-07-19-gui-web-client-architecture.zh.md: e8a8121a1a495db7f5392e288f3bcada92c70495 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 8b4f940299..4448fd5c68 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 @@ -48,7 +48,7 @@ There is no component registration model besides slots — the former view and t **Scope addressing** mirrors the host's agent-scope idiom: services are root singletons whose methods take no sessionId — they read the caller's scope mark (`scopeOf(ctx)`). Inside a session scope, `ctx.conversation.send('hi', 'queue')` targets that session; cross-session calls re-target by switching ctx (`ctx.sessions.scope(id)!.conversation.send(...)`); calling a scoped method from root ctx throws. Client session scopes are minted like host agent scopes (a no-op plugin fiber + a scope-key extend), built lazily on first viewing and torn down only when the session is removed and unwatched — host-session death alone does not tear a scope (it freezes into a read-only viewport). -## The data object layer (`packages/client/runtime/src/client/sessions/`) +## The data object layer (`packages/api/session-controller/src/client/`) Frames enter, snapshots exit, the Conversation assembler sits between — React-free (zero React imports, grep-assertable): diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md index a86f9d6f71..e8a8121a1a 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md @@ -4,7 +4,7 @@ Status: implemented [English](2026-07-19-gui-web-client-architecture.md) | 中文 -> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 +> 分工线:通道无关的分层模型与 RPC 协议(消息模型/类型体系/约定面/客户端基类)见 [分层与 RPC 协议笔记](2026-07-19-gui-layering-and-rpc-protocol.zh.md);本篇 = 浏览器侧:client cordis 树如何装载、UI 插件如何经 slot 与服务组合、React-free 对象层如何以不可变快照供给 React。 ## Problem @@ -30,25 +30,25 @@ Status: implemented ## client cordis 树与装载链 -装载链——两类包(普通包 vs dsh.client 插件)、模块系统/插件治理器之分、host 独家撰写的带修订号 entry 图之上的双阶段 boot、热重载——归 [client 插件装载笔记](2026-07-23-client-plugin-loading-model.md) 所有。本篇赖以立足的事实:浏览器启动与 host 相同的 vendored `@cordisjs/plugin-loader`,由 client 模块系统(`ctx.modules`,`packages/client/modules`)填上其 `internal` 约定;凡带产品行为的单元都是 host 独家撰写的 `__DSH_BOOT__` 图里的 entry——每个生产插件包(含基础设施)都携带 `dsh.client` 声明、以 fetch 到达的 `./client` tsdown 闭包 bundle 供给,`immediately` 行的差别仅在 boot 第一阶段预取,而普通包(react 家族、cordis、尚未升格的库)保持打进壳、已播种、对图不可见;bundle 执行 `window.__ModuleLoader__.load({ id, factory })`,其 `require` 由 lazy CJS 模块表应答(种子词条 + 已登记工厂,首次 require 时物化并记忆化——跨插件值 import 是构建错误,协作走 cordis 服务);全局样式与 CSS Modules 都内联在其持有插件的 bundle 中,物化时注入为 ` - -

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 deleted file mode 100644 index 3f06d6fd41..0000000000 --- a/examples/acp-agent/web.cordis.snapshot.yml +++ /dev/null @@ -1,31 +0,0 @@ -# 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: '@deepseek-ai/cordis-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-http - name: '@deepseek-ai/dsh-web-fetch-http' - - 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-official - 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 deleted file mode 100644 index 08a7b223ea..0000000000 --- a/examples/acp-agent/web.cordis.yml +++ /dev/null @@ -1,21 +0,0 @@ -# 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: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - insert: - - id: web - name: '@deepseek-ai/dsh-web' - - id: web-fetch-http - name: '@deepseek-ai/dsh-web-fetch-http' - - 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/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml deleted file mode 100644 index e965ffee3a..0000000000 --- a/examples/headless-agent/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: 2f854924d4bfd2bf66b3d6f47433136098d7b762 -README.zh.md: 4128645da8a0f8a84b17d819fc614051b68f2a11 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md deleted file mode 100644 index 2f854924d4..0000000000 --- a/examples/headless-agent/README.md +++ /dev/null @@ -1,32 +0,0 @@ -# headless-agent - -English | [中文](README.zh.md) - -This directory owns the replay and real-model test composition for a headless coding agent: DeepSeek V4 + local bash and filesystem tools + subagent delegation + workflows and fresh-agent Ralph iteration + `todo_write` + JSONL persistence. It explicitly mounts the shared agent spine, one root agent, persistence, and checkpoint policy; it is not a second product entry point. - -## Run it - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm dsh --profile headless "fix the failing test in this workspace" -``` - -The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. - -Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. - -## E2B POC overlay - -[`e2b.cordis.yml`](e2b.cordis.yml) replaces the local filesystem and subprocess providers with one shared E2B sandbox while retaining `dsh-bash-local` and the same model-facing tools. Put `E2B_API_KEY` beside `DEEPSEEK_API_KEY` in the gitignored root `.env`, then run the credential-gated live composition, which drives FS, Bash, PTY, and LSP in one sandbox and proves final deletion: - -```sh -pnpm exec vitest run --config vitest.e2e.config.ts packages/e2b/e2b/tests/composition.e2e.ts -``` - -The overlay creates the same absolute cwd inside the sandbox, but it does not upload or mount the host workspace. File and Bash mutations exist only in E2B; Cordis, model calls, agent/session state, session logs, skills, and SDK buffers remain on the host. The composition kills its sandbox on timeout and disposal. It is a provider-composition POC, not a whole-harness migration or a workspace-sync feature. - -## Advanced configuration - -[`advanced.cordis.yml`](advanced.cordis.yml) adds Code Mode and the Cordis tools to the test composition. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md deleted file mode 100644 index 4128645da8..0000000000 --- a/examples/headless-agent/README.zh.md +++ /dev/null @@ -1,32 +0,0 @@ -# headless-agent - -[English](README.md) | 中文 - -本目录负责 headless coding agent(智能体)的回放和真实模型测试组装:DeepSeek V4 + 本地 bash 与文件系统工具 + subagent 委托 + 工作流与全新 agent Ralph 迭代 + `todo_write` + JSONL 持久化。本目录显式挂载共享 agent 主干、一个根 agent、持久化和检查点策略;它不是第二个产品入口。 - -## 运行 - -```sh -# repo root .env (gitignored) or exported env: -# DEEPSEEK_API_KEY=sk-… -# DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm dsh --profile headless "fix the failing test in this workspace" -``` - -产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。 - -快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 - -## E2B POC overlay - -[`e2b.cordis.yml`](e2b.cordis.yml) 使用一个共享 E2B 沙箱替换本地文件系统与子进程提供方,同时保留 `dsh-bash-local` 和相同的面向模型工具。请在 git 忽略的根目录 `.env` 中,将 `E2B_API_KEY` 与 `DEEPSEEK_API_KEY` 放在一起,然后运行凭据门控的实机组合测试;它在同一个沙箱中驱动 FS、Bash、PTY 和 LSP,并证明沙箱最终被删除: - -```sh -pnpm exec vitest run --config vitest.e2e.config.ts packages/e2b/e2b/tests/composition.e2e.ts -``` - -该 overlay 会在沙箱中创建相同的绝对 cwd,但不会上传或挂载宿主工作区。文件与 Bash 变更只存在于 E2B;Cordis、模型调用、agent/会话状态、会话日志、skill(技能)和 SDK 缓冲仍在宿主上。该组合会在超时和资源释放时终止其沙箱。它是提供方组合 POC,而不是完整 harness 迁移或工作区同步功能。 - -## 高级配置 - -[`advanced.cordis.yml`](advanced.cordis.yml) 在测试组装中添加 Code Mode 和 Cordis 工具。 diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml deleted file mode 100644 index 87b18a7dd0..0000000000 --- a/examples/headless-agent/advanced.cordis.snapshot.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Replay counterpart to advanced.cordis.yml. It includes the base `cordis.yml` -# directly — a config patch cannot target an entry behind a nested include — and -# restates advanced.cordis.yml's overlay (the agent and persistence configs plus the -# code-runtime and tool-cordis inserts) so the whole app config lives in one patch. -# It re-pins `deepseek-v4-flash`: `cordis.yml` ships `deepseek-v4-pro`, but the -# recorded corpus (request headers, provenance) was captured on flash, so replay -# holds the recorded model to stay reproducible without a re-record. It also -# disables the key-requiring DeepSeek adapter and inserts `llm-replay` to serve -# recorded JSONL without a key or network. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-flash - cwd: !!js process.cwd() - workspaceContext: - maxBytes: 65536 - tools: - mode: both - persona: | - You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. - - Verify your work by running the code or tests. Keep answers brief and factual. - - id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - # Replay fixtures are raw JSONL; the whole-config patch must restate - # the compression choice or the default zstd frames hide the logs. - compression: none - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker-thread' - - id: cordis-host-runner - name: '@deepseek-ai/dsh-cordis-host-runner' - - id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml deleted file mode 100644 index 95e5ebf622..0000000000 --- a/examples/headless-agent/advanced.cordis.yml +++ /dev/null @@ -1,34 +0,0 @@ -# Add Code Mode and Cordis tools to the headless spawn/workflow stack. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: deepseek-official - model: deepseek-v4-pro - cwd: !!js process.cwd() - workspaceContext: - maxBytes: 65536 - tools: - mode: both - persona: | - You are headless-agent, a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. - - Verify your work by running the code or tests. Keep answers brief and factual. - - id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - - insert: - - id: code-runtime - name: '@deepseek-ai/dsh-code-runtime-worker-thread' - - id: cordis-host-runner - name: '@deepseek-ai/dsh-cordis-host-runner' - - id: tool-cordis - name: '@deepseek-ai/dsh-tool-cordis' diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml deleted file mode 100644 index e97e43d7d9..0000000000 --- a/examples/headless-agent/compaction.cordis.snapshot.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Keyless context-overflow composition for the assembled compaction snapshot. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: compaction-basic - name: '@deepseek-ai/dsh-compaction-basic' - config: - thresholdRatio: 0.99 - retainTokens: 20 - maxTokens: 32 - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' - config: - providers: - - id: deepseek-official - models: - - id: deepseek-v4-flash - contextWindow: 128000 diff --git a/examples/headless-agent/composition.md b/examples/headless-agent/composition.md deleted file mode 100644 index c983e62999..0000000000 --- a/examples/headless-agent/composition.md +++ /dev/null @@ -1,93 +0,0 @@ - - -# Headless Agent Snapshot Composition - -The headless snapshot composition combines the real DeepSeek adapter and coding capabilities with one explicitly configured persisted top-level agent; its JSONL driver is test-only. - -```mermaid -flowchart LR - cfg["examples/headless-agent
cordis.yml"] - plugin_headless_settings["settings
@deepseek-ai/dsh-settings-file"] - cfg --> plugin_headless_settings - plugin_headless_credentials["credentials
@deepseek-ai/dsh-credentials-local"] - cfg --> plugin_headless_credentials - 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_agent_spine["agent-spine
@deepseek-ai/dsh-agent-spine-demo"] - cfg --> plugin_headless_agent_spine - plugin_headless_persistence["persistence
@deepseek-ai/dsh-session-persistence-jsonl"] - cfg --> plugin_headless_persistence - plugin_headless_checkpoint_policy["checkpoint-policy
@deepseek-ai/dsh-session-checkpoint-policy"] - cfg --> plugin_headless_checkpoint_policy - plugin_headless_token_meter["token-meter
@deepseek-ai/dsh-token-meter"] - cfg --> plugin_headless_token_meter - plugin_headless_compaction_basic["compaction-basic
@deepseek-ai/dsh-compaction-basic"] - cfg --> plugin_headless_compaction_basic - plugin_headless_session_projection["session-projection
@deepseek-ai/dsh-session-projection"] - cfg --> plugin_headless_session_projection - plugin_headless_subagent["subagent
@deepseek-ai/dsh-subagent"] - cfg --> plugin_headless_subagent - plugin_headless_subagent_spawn_in_process["subagent-spawn-in-process
@deepseek-ai/dsh-subagent-spawn-in-process"] - cfg --> plugin_headless_subagent_spawn_in_process - plugin_headless_subagent_fork_in_process["subagent-fork-in-process
@deepseek-ai/dsh-subagent-fork-in-process"] - cfg --> plugin_headless_subagent_fork_in_process - plugin_headless_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] - cfg --> plugin_headless_tool_subagent_control - plugin_headless_tool_subagent_report["tool-subagent-report
@deepseek-ai/dsh-tool-subagent-report"] - cfg --> plugin_headless_tool_subagent_report - plugin_headless_tool_subagent["tool-subagent
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_headless_tool_subagent - plugin_headless_tool_subagent_fork["tool-subagent-fork
@deepseek-ai/dsh-tool-subagent"] - cfg --> plugin_headless_tool_subagent_fork - plugin_headless_workflow_worker_thread["workflow-worker-thread
@deepseek-ai/dsh-workflow-worker-thread"] - cfg --> plugin_headless_workflow_worker_thread - plugin_headless_tool_workflow["tool-workflow
@deepseek-ai/dsh-tool-workflow"] - cfg --> plugin_headless_tool_workflow - plugin_headless_tool_ralph["tool-ralph
@deepseek-ai/dsh-tool-ralph"] - cfg --> plugin_headless_tool_ralph - plugin_headless_tool_todo["tool-todo
@deepseek-ai/dsh-tool-todo"] - cfg --> plugin_headless_tool_todo - plugin_headless_fs_local["fs-local
@deepseek-ai/dsh-fs-local"] - cfg --> plugin_headless_fs_local - plugin_headless_fs_observation_policy["fs-observation-policy
@deepseek-ai/dsh-fs-observation-policy"] - cfg --> plugin_headless_fs_observation_policy - plugin_headless_tool_fs["tool-fs
@deepseek-ai/dsh-tool-fs"] - cfg --> plugin_headless_tool_fs -``` - -| Plugin id | Package / module | -| --- | --- | -| `settings` | `@deepseek-ai/dsh-settings-file` | -| `credentials` | `@deepseek-ai/dsh-credentials-local` | -| `llm-deepseek` | `@deepseek-ai/dsh-llm-deepseek` | -| `subprocess` | `@deepseek-ai/dsh-subprocess-local` | -| `bash` | `@deepseek-ai/dsh-bash-local` | -| `agent-spine` | `@deepseek-ai/dsh-agent-spine-demo` | -| `persistence` | `@deepseek-ai/dsh-session-persistence-jsonl` | -| `checkpoint-policy` | `@deepseek-ai/dsh-session-checkpoint-policy` | -| `token-meter` | `@deepseek-ai/dsh-token-meter` | -| `compaction-basic` | `@deepseek-ai/dsh-compaction-basic` | -| `session-projection` | `@deepseek-ai/dsh-session-projection` | -| `subagent` | `@deepseek-ai/dsh-subagent` | -| `subagent-spawn-in-process` | `@deepseek-ai/dsh-subagent-spawn-in-process` | -| `subagent-fork-in-process` | `@deepseek-ai/dsh-subagent-fork-in-process` | -| `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | -| `tool-subagent-report` | `@deepseek-ai/dsh-tool-subagent-report` | -| `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | -| `tool-subagent-fork` | `@deepseek-ai/dsh-tool-subagent` | -| `workflow-worker-thread` | `@deepseek-ai/dsh-workflow-worker-thread` | -| `tool-workflow` | `@deepseek-ai/dsh-tool-workflow` | -| `tool-ralph` | `@deepseek-ai/dsh-tool-ralph` | -| `tool-todo` | `@deepseek-ai/dsh-tool-todo` | -| `fs-local` | `@deepseek-ai/dsh-fs-local` | -| `fs-observation-policy` | `@deepseek-ai/dsh-fs-observation-policy` | -| `tool-fs` | `@deepseek-ai/dsh-tool-fs` | - -Source config: [`examples/headless-agent/cordis.yml`](cordis.yml). - -Maintenance mode: hybrid: the leaf plugin list is parsed from its `cordis.yml`; app package expansion is curated from package source. diff --git a/examples/headless-agent/cordis.yml b/examples/headless-agent/cordis.yml deleted file mode 100644 index 6dcde61110..0000000000 --- a/examples/headless-agent/cordis.yml +++ /dev/null @@ -1,166 +0,0 @@ -# One-shot coding agent with format-pure stdout. The app bin loads the -# gitignored root `.env` into the process environment; entry configs here are -# the composition base, while user-plane values resolve per request through -# the two providers below. - -# User-settings document (`$DSH_HOME/settings.yaml`, hot-reloaded): a -# `llm-deepseek:` section there overrides the adapter entry below without a -# restart. -- id: settings - name: '@deepseek-ai/dsh-settings-file' - -# Credential store: the live process environment over `$DSH_HOME/.credentials.yaml` -# (owner-only file, hot-reloaded). The adapter resolves `DEEPSEEK_API_KEY` -# through it at each request, so no key is inlined in this file. -- id: credentials - name: '@deepseek-ai/dsh-credentials-local' - -# The DeepSeek adapter. Swap to '@deepseek-ai/dsh-llm-pi-ai' for the pi-ai-backed -# twin (a `providers` dict keyed by route; `reasoning: high` replaces -# thinking/reasoningEffort). Shipped default: full thinking at max effort on -# every request. Exact-model resolution materializes request defaults before -# the request header is logged. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - thinking: enabled - reasoningEffort: max - models: - - id: deepseek-v4-pro - contextWindow: 128000 - - 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: - timeoutMs: 60000 - -# The example composition pre-creates one fresh `main` agent for its test driver. -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: deepseek-official - # Stays on flash: the goal/ralph replay corpora were recorded on it, and - # their nested-include overlays cannot re-pin the app config (a config - # patch cannot target an entry behind a nested include). - model: deepseek-v4-flash - cwd: !!js process.cwd() - workspaceContext: - maxBytes: 65536 - persona: | - You are headless-agent, a coding assistant powered by the {{model}} model. - - Verify your work by running the code or tests. Keep answers brief and - factual. - -- id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - compression: !!js "process.env.DSH_SNAPSHOT === undefined ? 'zstd' : 'none'" - -- id: checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' - -# Summarize an older range when derived history approaches the context window. -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: compaction-basic - name: '@deepseek-ai/dsh-compaction-basic' - config: - thresholdRatio: 0.8 - retainRatio: 0.16 - maxTokens: 8192 - compactionRetries: 1 - -# Projection registry: durable subagent identity (mode/label) folds through -# its registered units; subagent catalog reads fail loud without the capability. -- id: session-projection - name: '@deepseek-ai/dsh-session-projection' - -# Expose fresh-child `spawn` and completed-prefix `fork` through independent -# in-process backends. -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn-in-process - name: '@deepseek-ai/dsh-subagent-spawn-in-process' - config: - providerName: spawn - -- id: subagent-fork-in-process - name: '@deepseek-ai/dsh-subagent-fork-in-process' - config: - providerName: fork - -# Continuable background children are selected per delegation tool. The -# separately loaded control registers global `send_message`; `report` is -# installed only in continuable child scopes. -- id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - -- id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - backgroundMode: continuable - maxDepth: 1 - -# Fork stays one-shot because a continuable child's `report` tool and prompt -# section precede the inherited history a fork reuses; `run_in_background` is off -# as an explicit foreground-only choice even though agent-spine-demo mounts the -# generic Job runtime. See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. -- id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: one-shot - enableRunInBackground: false - maxDepth: 1 - -# The worker-thread workflow engine fans a model-written JavaScript script's -# `agent()` calls out through the spawn backend. -- id: workflow-worker-thread - name: '@deepseek-ai/dsh-workflow-worker-thread' - config: - provider: spawn - -- id: tool-workflow - name: '@deepseek-ai/dsh-tool-workflow' - -# A separate fixed consumer demonstrates fresh-agent Ralph iteration without -# changing the workflow tool or same-session goal behavior. -- id: tool-ralph - name: '@deepseek-ai/dsh-tool-ralph' - -# `todo_write` replaces the logged whole list. -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true - -# Policy loads before the model-facing filesystem tools so writes and edits -# require an observed file. Relative paths resolve from the process cwd. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.cwd() - -- id: fs-observation-policy - name: '@deepseek-ai/dsh-fs-observation-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' diff --git a/examples/headless-agent/e2b.cordis.yml b/examples/headless-agent/e2b.cordis.yml deleted file mode 100644 index 7467f9c06a..0000000000 --- a/examples/headless-agent/e2b.cordis.yml +++ /dev/null @@ -1,57 +0,0 @@ -# POC overlay: keep the advanced headless agent and model-facing tools, but -# place its filesystem and process substrate in one short-lived E2B sandbox; -# the generic Bash, PTY, and LSP consumers compose above them. -# -# One-world invariant: e2b.cwd, sandbox-policy.workspaceRoot, and bash-local's -# default workdir (implicit host process.cwd()) must all name the same remote -# directory. Only e2b.cwd is created at sandbox open; dropping its !!js line -# falls back to /home/user/workspace while Bash and PTY keep targeting the -# host path, so every tool call fails with a remote spawn error. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./advanced.cordis.yml - patches: - - id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - disabled: true - - id: fs-local - name: '@deepseek-ai/dsh-fs-local' - disabled: true - - insert: - - id: e2b - name: '@deepseek-ai/dsh-e2b' - config: - cwd: !!js process.cwd() - timeoutMs: 300000 - - id: subprocess-e2b - name: '@deepseek-ai/dsh-subprocess-e2b' - - id: fs-e2b - name: '@deepseek-ai/dsh-fs-e2b' - - id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.cwd() - - id: pty - name: '@deepseek-ai/dsh-terminal' - - id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - - id: tool-terminal - name: '@deepseek-ai/dsh-tool-terminal' - - id: lsp - name: '@deepseek-ai/dsh-lsp' - - id: lsp-stdio - name: '@deepseek-ai/dsh-lsp-stdio' - config: - servers: - typescript: - command: npx - args: [--yes, typescript-language-server@5.0.0, --stdio] - extensionToLanguage: - .ts: typescript - .tsx: typescriptreact - .js: javascript - .jsx: javascriptreact - - id: tool-lsp - name: '@deepseek-ai/dsh-tool-lsp' diff --git a/examples/headless-agent/package.json b/examples/headless-agent/package.json deleted file mode 100644 index c331af0f05..0000000000 --- a/examples/headless-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "headless-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Runnable demo: one complete headless coding-agent turn" -} diff --git a/examples/headless-agent/pty.cordis.snapshot.yml b/examples/headless-agent/pty.cordis.snapshot.yml deleted file mode 100644 index 8f3b817e4a..0000000000 --- a/examples/headless-agent/pty.cordis.snapshot.yml +++ /dev/null @@ -1,24 +0,0 @@ -# Keyless opt-in PTY composition for the headless stream-json snapshot. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - insert: - - id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - - id: pty - name: '@deepseek-ai/dsh-terminal' - - id: pty-snapshot-backend - name: '../acp-agent/pty-snapshot-backend.mjs' - - id: tool-terminal - name: '@deepseek-ai/dsh-tool-terminal' - config: - maxResultBytes: 64 - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/ralph.cordis.snapshot.yml b/examples/headless-agent/ralph.cordis.snapshot.yml deleted file mode 100644 index 87e5619cde..0000000000 --- a/examples/headless-agent/ralph.cordis.snapshot.yml +++ /dev/null @@ -1,12 +0,0 @@ -# Replay counterpart to cordis.yml for the shipped Ralph-loop snapshot. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - insert: - - id: llm-replay - name: '@deepseek-ai/dsh-llm-replay' diff --git a/examples/headless-agent/team.cordis.snapshot.yml b/examples/headless-agent/team.cordis.snapshot.yml deleted file mode 100644 index 1d0ab574f4..0000000000 --- a/examples/headless-agent/team.cordis.snapshot.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Keyless Agent Teams composition over the real headless app and deterministic fixture adapter. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./cordis.yml - patches: - - id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - disabled: true - - id: tool-subagent-control - name: '@deepseek-ai/dsh-tool-subagent-control' - disabled: true - - id: tool-subagent-report - name: '@deepseek-ai/dsh-tool-subagent-report' - disabled: true - - id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - backgroundMode: one-shot - maxDepth: 1 - - id: tool-subagent-fork - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: fork - toolName: subagent_fork - backgroundMode: one-shot - maxDepth: 1 - - insert: - - id: team - name: '@deepseek-ai/dsh-team' - - id: tool-team - name: '@deepseek-ai/dsh-tool-team' - - id: team-fixture-llm - name: './tests/fixtures/team-llm.mjs' diff --git a/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml b/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml deleted file mode 100644 index 661fb5440c..0000000000 --- a/examples/headless-agent/tests/fixtures/e2b/e2b/cordis.yml +++ /dev/null @@ -1,57 +0,0 @@ -# One-world invariant (same pairing as examples/headless-agent/e2b.cordis.yml): -# e2b.cwd and sandbox-policy.workspaceRoot must name the same remote directory, -# which is also bash-local's implicit default workdir. -- id: e2b - name: '@deepseek-ai/dsh-e2b' - config: - cwd: !!js process.cwd() - timeoutMs: 180000 - -- id: subprocess-e2b - name: '@deepseek-ai/dsh-subprocess-e2b' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - config: - timeoutMs: 30000 - -- id: fs-e2b - name: '@deepseek-ai/dsh-fs-e2b' - -- id: agents - name: '@deepseek-ai/dsh-agent' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.cwd() - -- id: pty - name: '@deepseek-ai/dsh-terminal' - -- id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - config: - pollIntervalMs: 25 - exactProbeAfterMs: 150 - idleSilenceMs: 2000 - handoffGraceMs: 500 - timeoutMs: 5000 - disposeGraceMs: 1000 - -- id: lsp - name: '@deepseek-ai/dsh-lsp' - -- id: lsp-stdio - name: '@deepseek-ai/dsh-lsp-stdio' - config: - servers: - fixture: - command: node - args: - - !!js process.cwd() + '/fixture-lsp.mjs' - extensionToLanguage: - .ts: typescript - shutdownTimeoutMs: 1000 - killGraceMs: 500 diff --git a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml b/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml deleted file mode 100644 index 6502dd845f..0000000000 --- a/examples/headless-agent/tests/fixtures/goal-domain/cordis.yml +++ /dev/null @@ -1,38 +0,0 @@ -# Test-only composition: create one goal through a Loader-mounted step consumer. -- 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' - -- id: goal - name: '@deepseek-ai/dsh-goal' - config: - defaultMaxGoalRounds: 11 - -- id: seed-goal - name: './seed-goal.ts' - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: cli-mock - model: cli-mock - cwd: !!js process.cwd() - persona: 'Test the persisted goal domain.' - workspaceContext: false - -- id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - compression: none - -- id: checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml b/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml deleted file mode 100644 index 4199bfb9ca..0000000000 --- a/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml +++ /dev/null @@ -1,8 +0,0 @@ -- id: agent-default-model - config: - provider: cli-mock - model: cli-mock - -- insert: - - id: cli-mock-llm - name: './snapshot-fixtures/cli-mock-llm.ts' diff --git a/examples/headless-agent/tests/fixtures/session-telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/session-telemetry-otel.cordis.yml deleted file mode 100644 index 4f603632c8..0000000000 --- a/examples/headless-agent/tests/fixtures/session-telemetry-otel.cordis.yml +++ /dev/null @@ -1,51 +0,0 @@ -# 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 -# session-telemetry/record waterfall — the seam itself ships no rules. -- id: logger-console - name: '@deepseek-ai/cordis-plugin-logger-console' - config: - colors: false - levels: - default: 3 - showTime: '' - -- id: cli-mock-llm - name: './cli-mock-llm.ts' - -- id: telemetry-redact-rule - name: './telemetry-redact-rule.ts' - -# Managed child-process groups required by the bash executor. -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: bash - name: '@deepseek-ai/dsh-bash-local' - -- id: session-telemetry-otel - name: '@deepseek-ai/dsh-session-telemetry-otel' - config: - mode: !!js process.env.DSH_TELEMETRY_E2E_MODE || 'FULL' - exporter: - url: !!js process.env.DSH_TELEMETRY_E2E_URL - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: cli-mock - model: cli-mock - cwd: !!js process.cwd() - persona: 'Test the session-telemetry-otel plugin.' - workspaceContext: false - -- id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - compression: 'none' - -- id: checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/headless-agent/tests/fixtures/time-context.cordis.yml b/examples/headless-agent/tests/fixtures/time-context.cordis.yml deleted file mode 100644 index ec6359d167..0000000000 --- a/examples/headless-agent/tests/fixtures/time-context.cordis.yml +++ /dev/null @@ -1,33 +0,0 @@ -# Test-only composition: keep time-context opt-in while exercising its real Loader/app path. -- 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' - -- id: time-context - name: '@deepseek-ai/dsh-time-context' - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: time-context-mock - model: time-context-mock - cwd: !!js process.cwd() - persona: 'Test the time-context plugin.' - workspaceContext: false - -- id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - compression: 'none' - -- id: checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts deleted file mode 100644 index 19247c3c6b..0000000000 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ /dev/null @@ -1,985 +0,0 @@ -import { copyFile, mkdir, readFile, readdir, writeFile } from 'node:fs/promises' -import { createServer } from 'node:http' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { delimiter, dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { - normalizeSessionLog, - normalizeStdout, - refreshFixtureReplacements, - scrubRequestHeaders, - stabilizeRefreshLog, - tokenizeSessionFixtureCwd, - type HarvestedLog, - type NormalizeContext, -} from '@deepseek-ai/dsh-acp-snapshot' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { - decompressZstdFrame, - scanZstdFrames, -} from '@deepseek-ai/dsh-session-persistence-jsonl/src/zstd.ts' -import { describe, expect, it } from 'vitest' - -const snapshotsDir = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') -const advancedScenarioDir = join(snapshotsDir, 'advanced-toolchain') -const advancedSessionFixture = join(advancedScenarioDir, 'session.jsonl') -const advancedStreamExpected = join(advancedScenarioDir, 'stream-json.expected.jsonl') -const advancedConfigPath = fileURLToPath(new URL('../advanced.cordis.snapshot.yml', import.meta.url)) -const ptyScenarioDir = join(snapshotsDir, 'pty-tools') -const ptySessionFixture = join(ptyScenarioDir, 'session.jsonl') -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 compactionScenarioDir = join(snapshotsDir, 'compaction-recovery') -const compactionSessionFixture = join(compactionScenarioDir, 'session.jsonl') -const compactionStreamExpected = join(compactionScenarioDir, 'stream-json.expected.jsonl') -const compactionConfigPath = fileURLToPath(new URL('../compaction.cordis.snapshot.yml', import.meta.url)) -const credentialsScenarioDir = join(snapshotsDir, 'missing-credential') -const credentialsConfigPath = fileURLToPath(new URL('../credentials.cordis.snapshot.yml', import.meta.url)) -// Same keyless composition as the missing-credential scenario: the endpoint is -// never dialed either way, because a supplied-but-unusable key fails credential -// resolution exactly where an absent one does. -const invalidCredentialScenarioDir = join(snapshotsDir, 'invalid-credential') -const ralphScenarioDir = join(snapshotsDir, 'ralph-loop') -const ralphConfigPath = fileURLToPath(new URL('../ralph.cordis.snapshot.yml', import.meta.url)) -const settlementScenarioDir = join(snapshotsDir, 'subagent-settlement') -const settlementConfigPath = fileURLToPath(new URL('../subagent-settlement.cordis.snapshot.yml', import.meta.url)) -const teamConfigPath = fileURLToPath(new URL('../team.cordis.snapshot.yml', import.meta.url)) -const startupFailureConfigPath = fileURLToPath(new URL('./fixtures/startup-activation-error/cordis.yml', import.meta.url)) -const startupFailureExpected = join(snapshotsDir, 'startup-activation-error', 'stderr.expected.txt') -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const dshBinScript = fileURLToPath(new URL('../../../apps/cli/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 deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) -const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url)) -const headlessSessionExpected = join(snapshotsDir, 'headless-profile', 'session.expected.jsonl') -const headlessFailureExpected = join(snapshotsDir, 'headless-profile', 'stderr.expected.txt') -const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' - -interface JsonObject { - [key: string]: unknown -} - -interface PersistedLog { - readonly content: string - readonly header: JsonObject -} - -interface DeepSeekDefaultsServer { - readonly url: string - readonly requests: JsonObject[] - close(): Promise -} - -/** Serve one deterministic DeepSeek-compatible response while retaining its request body. */ -async function deepseekDefaultsServer(): Promise { - const requests: JsonObject[] = [] - const server = createServer((request: IncomingMessage, response: ServerResponse) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) - request.on('end', () => { - requests.push(JSON.parse(body) as JsonObject) - response.writeHead(200, { 'content-type': 'text/event-stream' }) - let keepAlives = 3 - const write = (): void => { - if (keepAlives-- > 0) { - response.write(': keep-alive\n\n') - setTimeout(write, 60) - return - } - response.end([ - 'data: {"choices":[{"delta":{"content":"DEFAULTS_OK"}}]}', - 'data: {"choices":[{"delta":{},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}', - 'data: [DONE]', - '', - ].join('\n\n')) - } - setTimeout(write, 60) - }) - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const address = server.address() - if (address === null || typeof address === 'string') throw new Error('DeepSeek defaults snapshot server has no port') - return { - url: `http://127.0.0.1:${address.port}`, - requests, - close: () => new Promise(resolve => server.close(() => { resolve() })), - } -} - -function parseJsonl(content: string): JsonObject[] { - return content.split('\n') - .filter(line => line.trim().length > 0) - .map(line => JSON.parse(line) as JsonObject) -} - -function contextFromLogs(contents: readonly string[]): NormalizeContext { - const headers = contents.map(content => parseJsonl(content)[0]) - return { - sessionIds: headers.flatMap(header => typeof header?.id === 'string' ? [header.id] : []), - cwd: typeof headers[0]?.cwd === 'string' ? headers[0].cwd : '\0no-cwd\0', - } -} - -function normalizeHeadlessStream(rawStdout: string, cwd: string): string { - const records = parseJsonl(rawStdout) - if (records.length === 0) throw new Error('headless snapshot emitted no stream-json records') - const final = records.at(-1) - if (final?.type !== 'result') throw new Error('headless snapshot did not end with a result record') - if (records.slice(0, -1).some(record => record.type !== 'session_event')) { - throw new Error('headless snapshot emitted a non-event record before its result') - } - - const sessionIds = [...new Set(records.flatMap(record => typeof record.sessionId === 'string' ? [record.sessionId] : []))] - if (sessionIds.length !== 1) throw new Error(`headless snapshot streamed ${sessionIds.length} main session ids`) - const context: NormalizeContext = { sessionIds, cwd } - const events = records.slice(0, -1).map((record) => { - if (record.event === null || typeof record.event !== 'object' || Array.isArray(record.event)) { - throw new Error('headless snapshot emitted an invalid session event') - } - return record.event as JsonObject - }) - const normalizedEvents = parseJsonl(scrubRequestHeaders(normalizeSessionLog( - `${events.map(event => JSON.stringify(event)).join('\n')}\n`, - context, - ))) - const normalizedRecords = records.map((record, index) => index < normalizedEvents.length - ? { ...record, event: normalizedEvents[index] } - : record) - return normalizeStdout(`${normalizedRecords.map(record => JSON.stringify(record)).join('\n')}\n`, context) -} - -/** Zero durable goal timestamps inside both metadata records and rendered XML JSON. */ -function normalizeGoalTimestamps(value: unknown): unknown { - if (typeof value === 'string') { - return value.replace(/("(?:createdAt|updatedAt|clearedAt)":)\d+/g, '$10') - } - if (Array.isArray(value)) return value.map(normalizeGoalTimestamps) - if (value !== null && typeof value === 'object') { - return Object.fromEntries(Object.entries(value).map(([key, item]) => [ - key, - ['createdAt', 'updatedAt', 'clearedAt'].includes(key) && typeof item === 'number' - ? 0 - : normalizeGoalTimestamps(item), - ])) - } - return value -} - -/** Normalize the stream's durable goal timestamps after the shared scrubbers. */ -function normalizeGoalStream(rawStdout: string, cwd: string): string { - return parseJsonl(normalizeHeadlessStream(rawStdout, cwd)) - .map(record => JSON.stringify(normalizeGoalTimestamps(record))) - .join('\n') + '\n' -} - -async function scenarioPrompt(dir: string, label: string): Promise { - const input = JSON.parse(await readFile(join(dir, 'input.json'), 'utf8')) as { - steps?: { op?: unknown; text?: unknown }[] - } - const prompt = input.steps?.find(step => step.op === 'prompt')?.text - if (typeof prompt !== 'string') throw new Error(`${label} input has no prompt step`) - return prompt -} - -async function readPersistedLog(file: string): Promise { - const content = await readFile(file) - if (!file.endsWith('.zstd')) return content.toString('utf8') - const scan = scanZstdFrames(content) - if (scan.tornStart !== undefined) throw new Error(`persisted snapshot log has a torn Zstandard frame: ${file}`) - const decoded: Buffer[] = [] - for (const frame of scan.frames) { - decoded.push(await decompressZstdFrame(content.subarray(frame.start, frame.end))) - } - return Buffer.concat(decoded).toString('utf8') -} - -async function persistedLogs(cwd: string, root: string = join(cwd, '.sessions')): Promise { - const files = (await readdir(root, { recursive: true })) - .filter(file => file.endsWith('.jsonl') || file.endsWith('.jsonl.zstd')) - return Promise.all(files.map(async (file) => { - const content = await readPersistedLog(join(root, file)) - return { content, header: parseJsonl(content)[0] ?? {} } - })) -} - -/** Install the keyless product-CLI adapter into the temporary headless profile. */ -async function prepareCliMockFixture(cwd: string): Promise { - const fixtureDir = join(cwd, '.dsh', 'profiles', 'headless', 'snapshot-fixtures') - await mkdir(fixtureDir, { recursive: true }) - await Promise.all([ - copyFile(cliMockLlmPluginPath, join(fixtureDir, 'cli-mock-llm.ts')), - writeFile(join(fixtureDir, 'package.json'), '{"type":"module"}\n'), - ]) -} - -describe('headless stream-json snapshots', () => { - it('runs one task through the product headless profile command', async () => { - const task = 'Prove the product headless profile path with one real tool round trip.' - const result = await runLoaderSmoke({ - label: 'product headless profile snapshot', - tempDirPrefix: 'headless-snapshot-profile-', - binScript: dshBinScript, - configPath: headlessOverlayPath, - binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task], - tsconfigPath, - env: { - DSH_PERMISSION_MODE: 'danger-full-access', - DSH_TELEMETRY_DISABLED: '1', - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - prepare: prepareCliMockFixture, - inspect: async (cwd) => { - const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) - expect(logs).toHaveLength(1) - const actual = logs[0] - if (actual === undefined) throw new Error('the headless profile did not persist its session') - const context = contextFromLogs([actual.content]) - const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) - if (refreshing) await writeFile(headlessSessionExpected, session) - expect(session).toBe(await readFile(headlessSessionExpected, 'utf8')) - expect(session).toContain(task) - expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') - }, - }) - - expect(result.stdout).toBe('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP\n') - expect(result.stderr).toBe('') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('prints a terminal model failure through the product headless profile command', async () => { - const result = await runLoaderSmoke({ - label: 'product headless profile model failure snapshot', - tempDirPrefix: 'headless-snapshot-profile-failure-', - binScript: dshBinScript, - configPath: headlessOverlayPath, - binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, 'Trigger the keyless model failure.'], - tsconfigPath, - expectedExitCode: 1, - env: { - DSH_CLI_MOCK_FAILURE: '1', - DSH_TELEMETRY_DISABLED: '1', - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - prepare: prepareCliMockFixture, - }) - - expect(result.stdout).toBe('\n') - await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('prints the original Loader activation error through the assembled one-shot app', async () => { - const result = await runLoaderSmoke({ - label: 'headless startup activation error snapshot', - tempDirPrefix: 'headless-snapshot-startup-error-', - binScript, - libBinScript: binScript, - configPath: startupFailureConfigPath, - binArgs: [startupFailureConfigPath, 'unreachable task'], - tsconfigPath, - expectedExitCode: 1, - }) - expect(result.stdout).toBe('') - await expect(result.stderr).toMatchFileSnapshot(startupFailureExpected) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - 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, - libBinScript: binScript, - configPath: retryConfigPath, - binArgs: [retryConfigPath, 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-official', - 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('recovers from context overflow through an assembled compaction', async () => { - const prompt = await scenarioPrompt(compactionScenarioDir, 'compaction-recovery') - let expectedSession = await readFile(compactionSessionFixture, 'utf8') - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'compaction recovery headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-compaction-recovery-', - binScript, - libBinScript: binScript, - configPath: compactionConfigPath, - binArgs: [compactionConfigPath, prompt], - tsconfigPath, - env: { - DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: compactionSessionFixture, - 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 actual = logs[0] - if (actual === undefined) throw new Error('compaction snapshot did not persist its session') - const records = parseJsonl(actual.content) - const types = records.map(record => record.type) - expect(types.filter(type => type === 'compaction/start')).toHaveLength(1) - expect(types.filter(type => type === 'compaction/summary')).toHaveLength(1) - expect(types.filter(type => type === 'compaction/end')).toHaveLength(1) - const start = types.indexOf('compaction/start') - const summary = types.indexOf('compaction/summary') - const replacement = records.findIndex((record) => { - if (record.type !== 'user/message') return false - const surfaceOp = record.surfaceOp as JsonObject | undefined - return surfaceOp?.op === 'replace' - }) - const end = types.indexOf('compaction/end') - expect(start).toBeLessThan(summary) - expect(summary).toBeLessThan(replacement) - expect(replacement).toBeLessThan(end) - const summaryRecord = records[summary] - const summaryData = summaryRecord?.data as JsonObject | undefined - expect(summaryData?.shadowedSeqs).toEqual(expect.arrayContaining([expect.any(Number)])) - const final = [...records].reverse().find(record => record.type === 'assistant/message') - expect(JSON.stringify(final)).toContain('COMPACTION RECOVERED') - - const actualContext = contextFromLogs([actual.content]) - if (refreshing) { - const harvested: HarvestedLog = { - id: String(actual.header.id), - createdAt: Number(actual.header.createdAt), - content: actual.content, - } - const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = tokenizeSessionFixtureCwd( - stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), - ) - await writeFile(compactionSessionFixture, expectedSession) - } - const expectedContext = contextFromLogs([expectedSession]) - expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) - .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) - }, - }) - - expect(result.stderr).toBe('') - const normalized = normalizeHeadlessStream(result.stdout, runCwd) - if (refreshing) await writeFile(compactionStreamExpected, normalized) - expect(normalized).toBe(await readFile(compactionStreamExpected, 'utf8')) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('logs actionable missing-credential guidance through the one-shot app', async () => { - const streamExpected = join(credentialsScenarioDir, 'stream-json.expected.jsonl') - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'missing-credential headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-missing-credential-', - binScript, - libBinScript: binScript, - configPath: credentialsConfigPath, - binArgs: [credentialsConfigPath, 'say pong'], - tsconfigPath, - env: { - // First-run posture: no key in the environment, none under ./.dsh. - DEEPSEEK_API_KEY: '', - DEEPSEEK_BASE_URL: '', - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - prepare: (cwd) => { runCwd = cwd }, - }) - - // The failure reaches the caller through the stream, not stderr; the - // recorded transcript below pins the guidance text itself, which names - // both places a credential can come from and nothing else. - expect(result.stderr).toBe('') - const normalized = normalizeHeadlessStream(result.stdout, runCwd) - if (refreshing) await writeFile(streamExpected, normalized) - expect(normalized).toBe(await readFile(streamExpected, 'utf8')) - // The durable failure leads with the credential store — the path that - // keeps the secret out of configuration files — then names the launching - // environment, and stops there: configuration carries the reference, so - // there is no literal-key escape hatch left to offer. - expect(normalized).toContain( - 'store DEEPSEEK_API_KEY through the credentials service (the web Models page writes it),', - ) - expect(normalized).toContain('or export DEEPSEEK_API_KEY in the launching environment') - expect(normalized).not.toContain('as a last resort') - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('logs actionable invalid-credential guidance through the one-shot app', async () => { - const streamExpected = join(invalidCredentialScenarioDir, 'stream-json.expected.jsonl') - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'invalid-credential headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-invalid-credential-', - binScript, - libBinScript: binScript, - configPath: credentialsConfigPath, - binArgs: [credentialsConfigPath, 'say pong'], - tsconfigPath, - env: { - // A key that exists but no HTTP header can carry — the paste the - // credential guard exists for: without it, `fetch` refuses to build - // the header and the turn ends on a retried ByteString TypeError. - DEEPSEEK_API_KEY: 'sk-\u{1F600}pasted-from-a-chat-window', - DEEPSEEK_BASE_URL: '', - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - prepare: (cwd) => { runCwd = cwd }, - }) - - expect(result.stderr).toBe('') - const normalized = normalizeHeadlessStream(result.stdout, runCwd) - if (refreshing) await writeFile(streamExpected, normalized) - expect(normalized).toBe(await readFile(streamExpected, 'utf8')) - // The durable failure names the reference to correct and the writer that - // usually owns it, and stays true in a composition that mounts no Models - // page at all. - expect(normalized).toContain('the API key resolved from DEEPSEEK_API_KEY contains characters') - expect(normalized).toContain('the web Models page writes it') - // Neither the key nor its transport-level symptom (the ByteString error) - // may reach the user: the code point of one character is still the key. - expect(normalized).not.toContain('pasted-from-a-chat-window') - expect(normalized).not.toContain('ByteString') - }, 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, - libBinScript: binScript, - configPath: reasoningConfigPath, - binArgs: [reasoningConfigPath, '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('keeps provider comments alive and sends DeepSeek defaults through the one-shot app', async () => { - const server = await deepseekDefaultsServer() - try { - const result = await runLoaderSmoke({ - label: 'DeepSeek adapter defaults headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-deepseek-defaults-', - binScript, - libBinScript: binScript, - configPath: deepseekDefaultsConfigPath, - binArgs: [ - deepseekDefaultsConfigPath, - 'return the deterministic response', - ], - tsconfigPath, - env: { - // Configuration carries only the reference; the key rides the - // launching environment, which is the whole credential plane here. - DEEPSEEK_API_KEY: 'snapshot-key', - DSH_SNAPSHOT_BASE_URL: server.url, - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - }) - - expect(result.stderr).toBe('') - expect(server.requests).toHaveLength(1) - expect(server.requests[0]?.max_tokens).toBe(256_000) - expect(server.requests[0]?.reasoning_effort).toBe('low') - const header = (parseJsonl(result.stdout) - .map(record => record.event) - .find((event): event is JsonObject => ( - event !== null - && typeof event === 'object' - && !Array.isArray(event) - && 'type' in event - && event.type === 'request/header' - ))?.data as JsonObject | undefined)?.header as JsonObject | undefined - expect(header?.config).toMatchInlineSnapshot(` - { - "maxTokens": 256000, - "model": "deepseek-v4-flash", - "provider": "deepseek-official", - "reasoningEffort": "low", - } - `) - expect(header?.adapterDefaults).toEqual({ - maxTokens: true, - reasoningEffort: true, - }) - } finally { - await server.close() - } - }, 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 = [ - advancedSessionFixture, - join(advancedScenarioDir, 'session.1.jsonl'), - join(advancedScenarioDir, 'session.2.jsonl'), - ] - let expectedSessions = await Promise.all(fixtureFiles.map(file => readFile(file, 'utf8'))) - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'advanced headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-advanced-', - binScript, - libBinScript: binScript, - configPath: advancedConfigPath, - binArgs: [advancedConfigPath, prompt], - tsconfigPath, - env: { - DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: advancedSessionFixture, - DSH_SNAPSHOT_CHILD_FILES: [ - join(advancedScenarioDir, 'session.1.jsonl'), - join(advancedScenarioDir, 'session.2.jsonl'), - ].join(delimiter), - 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(3) - const parents = logs.filter(log => typeof log.header.parentSession !== 'string') - expect(parents).toHaveLength(1) - const parent = parents[0] - if (parent === undefined) throw new Error('headless snapshot did not persist its main session') - 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), - createdAt: Number(log.header.createdAt), - ...typeof log.header.parentSession === 'string' - ? { parentSession: log.header.parentSession } - : {}, - content: log.content, - })) - const replacements = refreshFixtureReplacements(harvested, expectedSessions) - expectedSessions = await Promise.all(actualSessions.map(async (actual, index) => { - const existing = expectedSessions[index] - const file = fixtureFiles[index] - if (existing === undefined || file === undefined) { - throw new Error(`headless snapshot has no fixture for persisted log ${index}`) - } - const stable = tokenizeSessionFixtureCwd( - stabilizeRefreshLog(actual.content, existing, replacements, actualContext), - ) - await writeFile(file, stable) - return stable - })) - } - const expectedContext = contextFromLogs(expectedSessions) - for (const [index, actual] of actualSessions.entries()) { - const expected = expectedSessions[index] - if (expected === undefined) throw new Error(`headless snapshot has no fixture for persisted log ${index}`) - expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) - .toBe(scrubRequestHeaders(normalizeSessionLog(expected, expectedContext))) - } - }, - }) - - expect(result.stderr).toBe('') - const normalized = normalizeHeadlessStream(result.stdout, runCwd) - if (refreshing) await writeFile(advancedStreamExpected, normalized) - expect(normalized).toBe(await readFile(advancedStreamExpected, 'utf8')) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('runs a keyless Agent Team with peer mail, dependent tasks, waiting, and Lead aggregation', async () => { - let projection: unknown - const result = await runLoaderSmoke({ - label: 'Agent Teams headless snapshot', - tempDirPrefix: 'headless-snapshot-agent-team-', - binScript, - libBinScript: binScript, - configPath: teamConfigPath, - binArgs: [ - teamConfigPath, - '请明确使用 Agent Teams,把调研和实现拆给两个 teammate,等待完成后汇总。', - ], - tsconfigPath, - processTimeoutMs: 60_000, - env: { - DSH_SNAPSHOT: 'team', - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - }, - inspect: async (cwd) => { - const logs = await persistedLogs(cwd) - const parent = logs.find(log => typeof log.header.parentSession !== 'string') - if (parent === undefined) throw new Error('Agent Teams snapshot did not persist its Lead') - const rows = parseJsonl(parent.content) - const members = rows.filter(row => row.type === 'team/member') - .map(row => ((row.data as JsonObject).member as JsonObject)) - const tasks = rows.filter(row => row.type === 'team/task') - .map(row => ((row.data as JsonObject).task as JsonObject)) - const latestTasks = Object.values(Object.fromEntries(tasks.map(task => [String(task.subject), task]))) - projection = { - sessions: logs.length, - memberEdges: members.length, - activeMembers: members.filter(member => member.phase === 'active').map(member => member.name).sort(), - tasks: latestTasks.map(task => ({ - subject: task.subject, - revision: task.revision, - status: task.status, - })).sort((left, right) => String(left.subject).localeCompare(String(right.subject))), - queuedMessages: rows.filter(row => row.type === 'team/message/queued').length, - deliveredMessages: rows.filter(row => row.type === 'team/message/delivered').length, - waited: rows.some(row => row.type === 'tool/call' - && (row.data as JsonObject).name === 'wait_agent'), - checkedRoster: rows.some(row => row.type === 'tool/call' - && (row.data as JsonObject).name === 'list_agents'), - } - }, - }) - expect(result.stderr).toBe('') - expect(parseJsonl(result.stdout).at(-1)).toMatchObject({ - type: 'result', - output: 'TEAM_WORKFLOW_OK: both teammates and dependent tasks completed.', - }) - expect(projection).toMatchInlineSnapshot(` - { - "activeMembers": [ - "implementer", - "researcher", - ], - "checkedRoster": true, - "deliveredMessages": 2, - "memberEdges": 4, - "queuedMessages": 2, - "sessions": 3, - "tasks": [ - { - "revision": 3, - "status": "completed", - "subject": "Implementation", - }, - { - "revision": 3, - "status": "completed", - "subject": "Research", - }, - ], - "waited": true, - } - `) - }, 75_000) - - it('replays persisted goal tools through the one-shot app', async () => { - const prompt = await scenarioPrompt(goalScenarioDir, 'goal-tools') - const streamExpected = join(goalScenarioDir, 'stream-json.expected.jsonl') - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'goal tools headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-goal-tools-', - binScript, - libBinScript: binScript, - configPath: goalConfigPath, - binArgs: [goalConfigPath, prompt], - tsconfigPath, - env: { - DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: join(goalScenarioDir, 'session.jsonl'), - DSH_SNAPSHOT_OVERRIDE: join(goalScenarioDir, 'replay.override.json'), - 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 calls = records.filter(record => record.type === 'tool/call') - .map(record => (record.data as JsonObject | undefined)?.name) - expect(calls).toEqual(['update_goal', 'create_goal', 'get_goal']) - const probeResult = records.find((record) => { - if (record.type !== 'tool/result') return false - const data = record.data as JsonObject | undefined - const message = data?.message as JsonObject | undefined - const source = message?.source as JsonObject | undefined - return source?.callId === 'call_goal_probe' - }) - const probeData = probeResult?.data as JsonObject | undefined - const probeMessage = probeData?.message as JsonObject | undefined - const probeContent = probeMessage?.content as JsonObject[] | undefined - expect(probeContent?.[0]?.isError).toBe(true) - expect((probeData?.error as JsonObject | undefined)?.code).toBe('GOAL_NOT_FOUND') - const goalChanges = records.filter(record => record.type === 'goal/change') - expect(goalChanges).toHaveLength(1) - const data = goalChanges[0]?.data as JsonObject | undefined - const goal = data?.goal as JsonObject | undefined - expect(data?.operation).toBe('create') - expect(goal).toMatchObject({ - objective: 'Finish the headless goal-tool snapshot proof', - phase: 'active', - maxGoalRounds: 7, - }) - }, - }) - - expect(result.stderr).toBe('') - const normalized = normalizeGoalStream(result.stdout, runCwd) - if (refreshing) await writeFile(streamExpected, normalized) - expect(normalized).toBe(await readFile(streamExpected, 'utf8')) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('replays two fresh Ralph rounds through the one-shot app', async () => { - const prompt = await scenarioPrompt(ralphScenarioDir, 'ralph-loop') - const streamExpected = join(ralphScenarioDir, 'stream-json.expected.jsonl') - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'Ralph loop headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-ralph-loop-', - binScript, - libBinScript: binScript, - configPath: ralphConfigPath, - binArgs: [ralphConfigPath, prompt], - tsconfigPath, - env: { - DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: join(ralphScenarioDir, 'session.jsonl'), - DSH_SNAPSHOT_OVERRIDE: join(ralphScenarioDir, 'replay.override.json'), - DSH_SNAPSHOT_CHILD_FILES: [ - join(ralphScenarioDir, 'session.1.jsonl'), - join(ralphScenarioDir, 'session.2.jsonl'), - ].join(delimiter), - 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(3) - const parent = logs.find(log => typeof log.header.parentSession !== 'string') - if (parent === undefined) throw new Error('Ralph snapshot did not persist its parent session') - const parentId = parent.header.id - expect(typeof parentId).toBe('string') - const children = logs.filter(log => typeof log.header.parentSession === 'string') - .sort((left, right) => Number(left.header.createdAt) - Number(right.header.createdAt)) - expect(children).toHaveLength(2) - expect(children.map(child => child.header.parentSession)).toEqual([parentId, parentId]) - expect(children.map(child => child.header.cwd)).toEqual([parent.header.cwd, parent.header.cwd]) - expect(parent.header.delegationDepth).toBe(0) - expect(children.map(child => child.header.delegationDepth)).toEqual([1, 1]) - expect(children.map(child => child.header.seedLength)).toEqual([undefined, undefined]) - expect(new Set(children.map(child => child.header.id)).size).toBe(2) - - const parentRecords = parseJsonl(parent.content) - const parentCalls = parentRecords.filter(record => record.type === 'tool/call') - expect(parentCalls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['ralph']) - const parentResult = parentRecords.find(record => record.type === 'tool/result') - const parentResultData = parentResult?.data as JsonObject | undefined - const parentMessage = parentResultData?.message as JsonObject | undefined - const parentContent = parentMessage?.content as JsonObject[] | undefined - expect(parentContent?.[0]?.isError).toBe(false) - expect(JSON.stringify(parentContent?.[0]?.content)).toContain('reported completion after 2 rounds') - - const childRecords = children.map(child => parseJsonl(child.content)) - const childPrompts = childRecords.map((records) => { - const message = records.find(record => record.type === 'user/message') - return JSON.stringify((message?.data as JsonObject | undefined)?.content) - }) - expect(childPrompts[0]).toContain('Ralph round: 1 of 2.') - expect(childPrompts[0]).toContain('(none — this is the first round)') - expect(childPrompts[0]).not.toContain('ROUND_ONE_HANDOFF') - expect(childPrompts[1]).toContain('Ralph round: 2 of 2.') - expect(childPrompts[1]).toContain('ROUND_ONE_HANDOFF') - for (const childPrompt of childPrompts) { - expect(childPrompt).toContain('Prove two fresh Ralph rounds through the shipped headless app.') - expect(childPrompt).not.toContain('Run a two-round fresh-agent Ralph loop') - } - for (const records of childRecords) { - const calls = records.filter(record => record.type === 'tool/call') - expect(calls.map(record => (record.data as JsonObject | undefined)?.name)) - .toEqual(['structured_output']) - } - }, - }) - - 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('delivers a continuable child result without parent polling', async () => { - const parentReplay = join(settlementScenarioDir, 'parent.replay.jsonl') - const parentOverride = join(settlementScenarioDir, 'parent.override.json') - const childReplay = join(settlementScenarioDir, 'child.replay.jsonl') - const childExpected = join(settlementScenarioDir, 'child.expected.jsonl') - const streamExpected = join(settlementScenarioDir, 'stream-json.expected.jsonl') - const task = 'Start one continuable background subagent and answer from its completion notice. Do not call list_agents, send_message, job_output, or job_list.' - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'continuable settlement headless stream-json snapshot', - tempDirPrefix: 'headless-snapshot-subagent-settlement-', - binScript, - libBinScript: binScript, - configPath: settlementConfigPath, - binArgs: [settlementConfigPath, task], - tsconfigPath, - env: { - // The override fully supplies the parent script; the child fixture - // remains separate so replay binds it to the fresh child Session. - DSH_SNAPSHOT_FILE: parentReplay, - DSH_SNAPSHOT_OVERRIDE: parentOverride, - DSH_SNAPSHOT_CHILD_FILES: childReplay, - 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(2) - const parent = logs.find(log => typeof log.header.parentSession !== 'string') - const child = logs.find(log => typeof log.header.parentSession === 'string') - if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') - - const parentRecords = parseJsonl(parent.content) - const calls = parentRecords.filter(record => record.type === 'tool/call') - expect(calls.map(record => (record.data as JsonObject | undefined)?.name)).toEqual(['subagent']) - const callArguments = (calls[0]?.data as JsonObject | undefined)?.arguments - if (typeof callArguments !== 'string') throw new Error('subagent call did not persist its arguments') - expect(JSON.parse(callArguments)).not.toHaveProperty('run_in_background') - - const notices = parentRecords.flatMap((record) => { - if (record.type !== 'agent/inbox/spliced') return [] - const inserted = (record.data as JsonObject | undefined)?.inserted - if (!Array.isArray(inserted)) return [] - return (inserted as JsonObject[]).filter((message) => { - const source = message.source as JsonObject | undefined - return source?.kind === 'subagent-settled' - }) - }) - expect(notices).toHaveLength(1) - expect(JSON.stringify(notices[0])).toContain('CHILD_RESULT') - - const context = contextFromLogs([parent.content, child.content]) - const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child.content, context)) - if (refreshing) await writeFile(childExpected, normalizedChild) - expect(normalizedChild).toBe(await readFile(childExpected, 'utf8')) - expect(normalizedChild).toContain('CHILD_RESULT') - expect(normalizedChild).not.toContain('"name":"report"') - }, - }) - - expect(result.stderr).toBe('') - const records = parseJsonl(result.stdout) - expect(records.at(-1)).toMatchObject({ - type: 'result', - output: 'PARENT_RECEIVED_CHILD_RESULT', - }) - 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('replays persistent PTY tools through the one-shot app', async () => { - const input = JSON.parse(await readFile(join(ptyScenarioDir, 'input.json'), 'utf8')) as { - steps?: { op?: unknown; text?: unknown }[] - } - const prompt = input.steps?.find(step => step.op === 'prompt')?.text - if (typeof prompt !== 'string') throw new Error('pty-tools input has no prompt step') - let expectedSession = await readFile(ptySessionFixture, 'utf8') - let runCwd = '' - const result = await runLoaderSmoke({ - label: 'headless persistent PTY snapshot', - tempDirPrefix: 'headless-snapshot-pty-', - binScript, - libBinScript: binScript, - configPath: ptyConfigPath, - binArgs: [ptyConfigPath, prompt], - tsconfigPath, - env: { - DSH_SNAPSHOT: 'replay', - DSH_SNAPSHOT_FILE: ptySessionFixture, - 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 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), - createdAt: Number(actual.header.createdAt), - content: actual.content, - } - const replacements = refreshFixtureReplacements([harvested], [expectedSession]) - expectedSession = tokenizeSessionFixtureCwd( - stabilizeRefreshLog(actual.content, expectedSession, replacements, actualContext), - ) - await writeFile(ptySessionFixture, expectedSession) - } - const expectedContext = contextFromLogs([expectedSession]) - expect(scrubRequestHeaders(normalizeSessionLog(actual.content, actualContext))) - .toBe(scrubRequestHeaders(normalizeSessionLog(expectedSession, expectedContext))) - }, - }) - - expect(result.stderr).toBe('') - const normalized = normalizeHeadlessStream(result.stdout, runCwd) - if (refreshing) await writeFile(ptyStreamExpected, normalized) - expect(normalized).toBe(await readFile(ptyStreamExpected, 'utf8')) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts deleted file mode 100644 index 9eee7cd0ab..0000000000 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,49 +0,0 @@ -import { readFile, readdir } from 'node:fs/promises' -import { zstdDecompress } from 'node:zlib' -import { promisify } from 'node:util' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import type { SessionEvent } from '@deepseek-ai/dsh-session' - -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const configPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const decompress = promisify(zstdDecompress) - -describe('headless-agent keyless smoke', () => { - it('boots the real Loader tree, runs a real bash tool round trip, and persists the turn', async () => { - let persistedHeader: Record | undefined - const { stdout, stderr } = await runLoaderSmoke({ - label: 'headless-agent', - tempDirPrefix: 'headless-agent-smoke-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, 'prove the tool path'], - tsconfigPath, - inspect: async (cwd) => { - const files = await readdir(cwd, { recursive: true }) - const relativePath = files.find(file => file.endsWith('.jsonl.zstd')) - if (relativePath === undefined) return - const compressed = await readFile(join(cwd, relativePath)) - expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') - persistedHeader = JSON.parse((await decompress(compressed)).toString()) as Record - }, - }) - const lines = stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) - const events = lines.slice(0, -1).map(line => line['event'] as SessionEvent) - const result = lines.at(-1) - expect(stderr).toBe('') - expect(events.some(event => event.type === 'tool/call' && event.data.name === 'bash')).toBe(true) - const toolResult = events.find(event => event.type === 'tool/result') - expect(JSON.stringify(toolResult)).toContain('CLI_TOOL_ROUND_TRIP') - expect(result).toMatchObject({ - type: 'result', - usage: { inputTokens: 18, outputTokens: 8, cacheReadTokens: 2, reasoningTokens: 1 }, - }) - expect(String(result?.['output'])).toContain('CLI_TOOL_ROUND_TRIP') - expect(persistedHeader).toMatchObject({ type: 'session' }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl b/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl deleted file mode 100644 index 7af81fa449..0000000000 --- a/examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl +++ /dev/null @@ -1,25 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Perform one side-effecting remote mutation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":0,"data":{"turn":1,"step":1}} -{"type":"assistant/message","seq":3,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"surfaceOp":"append"} -{"type":"tool/call","seq":4,"time":0,"data":{"turn":1,"step":1,"callId":"unknown-outcome-call","name":"write_remote","arguments":"{\"value\":1}"}} -{"type":"tool/result","seq":5,"time":0,"data":{"turn":1,"step":1,"message":{"id":"interrupted-tool-result-unknown-outcome-call-5","role":"user","source":{"kind":"tool","callId":"unknown-outcome-call"},"content":[{"type":"tool-result","toolCallId":"unknown-outcome-call","isError":true,"content":[{"type":"text","text":"The tool call was interrupted after it was recorded, but no result was durably recorded. Its outcome is unknown. Decide whether to retry from the tool semantics: retry only if the operation is read-only or idempotent; if it may have side effects, first verify external state or ask the user. Do not retry blindly."}]}]},"error":{"name":"ToolOutcomeUnknownError","code":"TOOL_OUTCOME_UNKNOWN"}},"surfaceOp":"append","sourceEventSeqs":[4]} -{"type":"step/end","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":7,"time":0,"data":{"turn":1,"reason":{"kind":"interrupted"}}} -{"type":"session/end-seed","seq":8,"time":0,"data":{}} -{"type":"agent/inbox/spliced","seq":9,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":10,"time":0,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":11,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":12,"time":0,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":13,"time":0,"data":{"content":[{"type":"text","text":"Continue safely from the interrupted operation."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":14,"time":0,"data":{"title":"Perform one side-effecting remote mutati","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":16,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"I will verify the external state before deciding whether to retry the side-effecting operation."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[17,18,19,20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":23,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts deleted file mode 100644 index 4fa0271fb0..0000000000 --- a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { readFile, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { describe, expect, it } from 'vitest' - -const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'semantic-checkpoint-snapshots/tool-outcome-unknown') -const replayFixture = join(fixtureDir, 'replay.jsonl') -const replayOverride = join(fixtureDir, 'replay.override.json') -const sessionExpected = join(fixtureDir, 'session.expected.jsonl') -const configPath = fileURLToPath(new URL('../semantic-checkpoint.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const sessionId = SessionId('semantic-checkpoint-unknown-outcome') -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' -const task = 'Continue safely from the interrupted operation.' - -async function seedInterruptedSession(root: string, cwd: string): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - const meta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 1, - cwd, - delegationDepth: 0, - } - const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ - content: [{ type: 'text', text: 'Perform one side-effecting remote mutation.' }], source: { kind: 'user' }, - }), surfaceOp: 'append' }, - { type: 'step/start', seq: 2, time: 12, data: { turn: 1, step: 1 } }, - { - type: 'assistant/message', - seq: 3, - time: 13, - data: { - turn: 1, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'tool-call', id: CallId('unknown-outcome-call'), name: 'write_remote', arguments: '{"value":1}' }], - source: { - kind: 'model', - ...{ provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }, - }), - }, - surfaceOp: 'append', - }, - { - type: 'tool/call', - seq: 4, - time: 14, - data: { - turn: 1, - step: 1, - callId: CallId('unknown-outcome-call'), - name: 'write_remote', - arguments: '{"value":1}', - }, - }, - ] - try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) - const location = ctx.sessionPersistence.locate(meta) - if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') - return location.path - } finally { - await ctx.fiber.dispose() - } -} - -describe('semantic checkpoint recovery snapshot', () => { - it('resumes an unknown tool outcome through the headless stream-json app', async () => { - let cwd = '' - let sessionPath = '' - const result = await runLoaderSmoke({ - label: 'semantic checkpoint headless stream-json snapshot', - tempDirPrefix: 'dsh-semantic-snapshot-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, task], - tsconfigPath, - env: { - DSH_SNAPSHOT_FILE: replayFixture, - DSH_SNAPSHOT_OVERRIDE: replayOverride, - }, - prepare: async (runCwd) => { - cwd = runCwd - sessionPath = await seedInterruptedSession(join(runCwd, '.sessions'), runCwd) - }, - inspect: async () => { - const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } - const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) - if (refreshing) await writeFile(sessionExpected, session) - expect(session).toBe(await readFile(sessionExpected, 'utf8')) - expect(session).toContain('TOOL_OUTCOME_UNKNOWN') - expect(session).toContain('Do not retry blindly.') - }, - }) - - expect(result.stderr).toBe('') - const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) - expect(records.at(-1)).toMatchObject({ - type: 'result', - sessionId, - output: 'I will verify the external state before deciding whether to retry the side-effecting operation.', - }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts deleted file mode 100644 index 891582a388..0000000000 --- a/examples/headless-agent/tests/session-format-guard.snapshot.ts +++ /dev/null @@ -1,107 +0,0 @@ -/** - * Assembled-app regression for the session-format refusal surface: resuming a - * log written by a "newer" harness (format version ahead, or an unknown - * required event type) fails loud through the real Loader composition, and the - * error the product user sees names the direction and the raw log path. - * @module session-format-guard-snapshot - */ - -import { join, dirname } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import SessionStore, { - SESSION_FORMAT_VERSION, - SessionId, - type SessionEvent, - type SessionHeader, -} from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { describe, expect, it } from 'vitest' - -const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') -const replayFixture = join(fixtureDir, 'replay.jsonl') -const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -// The resumed-agent fixture in the shared config resumes exactly this id. -const sessionId = SessionId('workspace-context-resume') - -/** Persist one session with the given header version and events, returning its log path. */ -async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd } - try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) - const location = ctx.sessionPersistence.locate(meta) - if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') - return location.path - } finally { - await ctx.fiber.dispose() - } -} - -function closedTurn(): SessionEvent[] { - return [ - { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, - ] -} - -describe('session format guard through the assembled app', () => { - it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => { - let sessionPath = '' - const result = await runLoaderSmoke({ - label: 'newer-format resume refusal', - tempDirPrefix: 'dsh-format-guard-version-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, 'Try to resume.'], - tsconfigPath, - env: { DSH_SNAPSHOT_FILE: replayFixture }, - expectedExitCode: 1, - prepare: async (runCwd) => { - sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn()) - }, - }) - expect(result.stderr).toContain( - `session "${sessionId}" uses log format v${SESSION_FORMAT_VERSION + 99}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`, - ) - // macOS reports the temp dir via the /private symlink parent; assert the - // stable path suffix instead of the realpath-dependent prefix. - expect(result.stderr).toContain('(raw log: ') - expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('refuses to resume a log with an unknown required event type', async () => { - let sessionPath = '' - const result = await runLoaderSmoke({ - label: 'unknown-event resume refusal', - tempDirPrefix: 'dsh-format-guard-event-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, 'Try to resume.'], - tsconfigPath, - env: { DSH_SNAPSHOT_FILE: replayFixture }, - expectedExitCode: 1, - prepare: async (runCwd) => { - sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [ - ...closedTurn(), - { type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent, - ]) - }, - }) - expect(result.stderr).toContain( - `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, - ) - // macOS reports the temp dir via the /private symlink parent; assert the - // stable path suffix instead of the realpath-dependent prefix. - expect(result.stderr).toContain('(raw log: ') - expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json b/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json deleted file mode 100644 index 5c5d57d683..0000000000 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/input.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "steps": [ - { - "op": "initialize" - }, - { - "op": "newSession" - }, - { - "op": "prompt", - "text": "Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-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 deleted file mode 100644 index 8fb937dee2..0000000000 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ /dev/null @@ -1,19 +0,0 @@ -{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c66e310e-2597-4d01-85c8-2d70a9d831c0"}]}} -{"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} -{"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c66e310e-2597-4d01-85c8-2d70a9d831c0"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786373992181,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"fd0a0587-8df4-46b2-809a-317346a4c0f4"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786373992181,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs 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 job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job 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 job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs 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":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result.","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."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"02dd8a61-a39a-46d0-8f6f-457533271cae"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730501507,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl deleted file mode 100644 index e05489ba1f..0000000000 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ /dev/null @@ -1,19 +0,0 @@ -{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"}]}} -{"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"8b3cd23c-82f1-4903-8a3f-b9082059b40c"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786373992411,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"823e5037-9e96-4ef5-8c5b-cbe73b993ee2"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786373992411,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs 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 job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job 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 job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs 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":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result.","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."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cd78f077-1fad-4cdc-ab56-09d39d9095cd"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730501646,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl deleted file mode 100644 index 19e4ec8138..0000000000 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ /dev/null @@ -1,75 +0,0 @@ -{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"}]}} -{"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"8a0ac233-283c-4eb4-8bbd-5c50b7e99afe"},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\n# Dynamic Cordis Plugins\n\nDynamic Cordis plugins temporarily extend the current DSH process. A Plugin uses apply(ctx) to consume Services, listen to Events, provide Services, register model Tools, or register browser UI in Slots.\n\n- Plugin and Package definitions exist only in the current process. define itself does not modify repository source, configuration, or disk, and definitions do not survive a process restart.\n- The restricted execution environment prevents accidental misuse; it is not a security boundary for malicious code. Services obtained by dynamic code connect to the real runtime.\n\n## Make the user-facing plan clear first\n\n- Dynamic Cordis Plugins are one available implementation mechanism, not the default for every request. Consider whether one could help only when the user intends to design or create something, or when a temporary interface could materially aid the current work. The presence of these instructions or Tools, and discussion of Cordis itself, do not make a request a dynamic-Plugin task.\n- When Cordis is a plausible fit, infer the intended work target and lifetime from the request and conversation. Use it only when the outcome belongs to the current running harness and should be delivered as a temporary runtime extension. If that distinction is materially ambiguous, ask at most one concise question about the intended result or lifetime. Otherwise proceed with the matching workflow; do not require the user to know or choose Cordis as an implementation mechanism.\n- Once a dynamic Plugin is appropriate, decide whether the task creates a new Plugin or modifies the Plugin named by the user with @pluginId. Proceed directly when the goal is clear; do not ask for repeated confirmation.\n- Choose Host, Client, or both from the requested outcome. Do not propose a Client/browser UI when the task does not need visible page behavior, and do not avoid Client when the requested outcome is visual, interactive, or depends on page state. Host versus Client is an implementation choice; do not make the user choose it.\n- When a design direction or a potentially useful interface would materially affect the result, ask at most one concise outcome or creative-preference question and offer a few candidate directions. Otherwise proceed directly; do not conduct a multi-round interview or a complex questionnaire.\n- cordis_define only defines and presents code; it does not run it. After definition, explain the pluginId and packageId returned by the Host and whether the next step is a run or update.\n- cordis_run may require user approval. When it returns awaiting-approval, explain that the user must allow or reject it in the UI. Do not wait, retry, or claim that it is running.\n- When it returns starting, explain that the request has entered the asynchronous flow and the Client is still activating. starting does not mean success. Wait for the system to report the final result through steering context.\n- Do not request approval again after the user rejects it. After a technical failure, fix the same Plugin from its diagnostics; do not silently create a replacement Plugin.\n\n## Recommended workflow and Tools\n\nBefore creating, modifying, or repairing a Plugin, load the cordis-plugin-development Skill. The Skill provides requirement navigation, capability composition, complete examples, and troubleshooting. Treat Inspect Provider results as the source of truth for exact APIs.\n\n1. cordis_inspect_list: discover the current Host and Client Providers and their read-only query methods.\n2. cordis_inspect_query: use the returned platform, provider, method, and schema to query exact Service, Event, Builtin, Slot, Theme token, or Tool information.\n3. cordis_inspect_self: inspect the current Session's Plugins, Packages, version pointers, source, and diagnostics. Source is returned only when both pluginId and packageId are specified.\n4. cordis_define: create the first Package for a new Plugin or append an immutable Package to an existing Plugin. It defines code but does not run it.\n5. cordis_run: activate an exact Package. Use run for the first activation, restarting current, or rollback; use update to switch versions.\n6. cordis_stop: remove the current Run and pending approval request while retaining definitions, grants, and version pointers.\n7. cordis_undefine: permanently stop and delete a Plugin and all of its Packages. Use it only after confirming that the user no longer needs them.\n\n- Inspect and Catalog data only confirm capabilities, names, signatures, types, and registration protocols before code is written; they do not replace business APIs.\n- Query Service.listService and Event.listEvents without input to choose from their compact signature directories, then query the exact service or event before using it. Exact queries return the structured contract and only its referenced types.\n- At runtime, a Plugin must call real Services or listen to real Events. Do not cache, display, or depend on Inspect results as business data.\n\n## Identity, versions, and approval\n\n- pluginId identifies a Plugin that can be modified over time. For a new Plugin, submit only a semantic idPrefix of 3–6 lowercase English letters; the Host allocates the final ID.\n- packageId identifies one immutable Host/Client source version under a Plugin. To change code, define a new Package; never overwrite an old version.\n- pluginRunId identifies one activation attempt and connects its approval, Host/Client loading, private RPC, Run card, and errors.\n- currentPackageId is the most recent fully successful Package. Stopping, starting an update, or failing an update does not clear it.\n- nextPackageId is the target awaiting approval, being attempted, awaiting Client activation, or most recently failed.\n- A single check mark authorizes only the current Package; double check marks authorize future versions of the same Plugin. A grant remains in effect after a technical failure.\n- An update stops the old Run before starting the target Package. Failure does not automatically restart the old version; retry next with update or roll back to current with run.\n\nWhen the user enters @pluginId, the system injects identity, the default base Package, version pointers, and runtime status, but not source code:\n\n1. Call cordis_inspect_self(pluginId, packageId) to read the target source.\n2. Use cordis_define in existing mode to append a Package to the same Plugin.\n3. Call cordis_run in run or update mode according to the version relationship.\n\nNever silently create another Plugin for @pluginId. If the reference is unavailable because it was removed, belongs to another Session, or was lost on process restart, tell the user directly.\n\n## High-frequency errors that must be avoided\n\n### Services: ctx.get and inject\n\n- Read an optional Service with ctx.get('serviceName') by default and handle undefined.\n- Declare inject: ['serviceName'] on the returned Plugin object only when the Service is a hard dependency and the Plugin must enter waiting until Cordis reactivates it after the Service appears.\n- Read ctx.serviceName only after declaring that Service in inject. Never access an undeclared Service as a ctx property.\n\n```js\nreturn {\n inject: ['requiredService'],\n apply(ctx) {\n ctx.requiredService.someMethod()\n const optionalService = ctx.get('optionalService')\n if (optionalService !== undefined) optionalService.someMethod()\n },\n}\n```\n\n### Code: use plain JavaScript only\n\n- Host and Client code is not transformed by TypeScript, JSX, or a bundler.\n- Do not use TypeScript types, as, decorators, import, require, or JSX.\n- Client React code must use React.createElement(...); never write .\n- Do not assume that process, Buffer, window, document, fetch, native timers, or any other global is available. Query the corresponding platform's Builtins and Services first.\n\n### Data: do not serialize live data\n\n- Services, Events, Slots, Sessions, and their derived Cordis/DSH objects are internal live data, not ordinary JSON that can be dumped.\n- Do not apply JSON.stringify, structuredClone, recursive enumeration, full copying, or whole-object display to live data.\n- Read only the leaf fields required by the task, then construct the smallest owned data object without Host references.\n\n### Lifecycle: every side effect must be reversible\n\n- Services, Events, Tools, handlers, timers, Slots, styles, and theme overrides must all belong to the current Fiber.\n- Use ctx.effect(), ctx.on(), or official APIs that return a disposer so stop, update, or undefine removes every side effect.\n- The cordis-plugin-development Skill contains complete timer, Waterfall, Slot, theme, Tool, RPC, and React examples and troubleshooting guidance.\n\n## Host and Client\n\n- Host runs in the DSH Node.js process and is appropriate for files, networking, commands, Agent/Session access, Host Events, Services, model Tools, and JSON methods callable by the Client.\n- Client runs in the browser page and is appropriate for themes, layout, current page state, Tool cards, and Slot UI.\n- Host and Client communicate through Package-private JSON methods: Host uses harness.handle(method, handler), and Client uses host.call(method, args). The direction is Client→Host, and only lossless JSON may cross it.\n- Client UI must be registered in a queried Slot; apply() cannot directly return a React Element. Query Slots.listSubTree without root to choose from the compact purpose/topology tree, then query the exact root for its full registration contract and props before writing code.\n- See the Skill and Inspect Providers for Run-specific panels and exact Slot registration patterns.\n\n## Asynchronous results and recovery\n\n- Do not wait inside a Tool for approval or browser work that can happen only after the current turn ends.\n- Asynchronous success, rejection, and runtime errors update Run state and notify you through steering context.\n- After a technical failure, use cordis_inspect_self to read the exact Package source and its message/stack. Define a corrected Package under the same Plugin and retry autonomously.\n- Use the cordis-plugin-development Skill for other failure causes, repair procedures, and complete extension patterns.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.\n\n## Writing code for run_code\n\n`run_code` takes two required arguments: `code` — the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped) — and `description`, a short summary of what the program does. 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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs. */\n cordis_define: {\n plugin: {\n kind: \"new\";\n /** Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix. */\n idPrefix: string;\n } | {\n kind: \"existing\";\n /** Exact ID of an existing Plugin; the new Package is appended to that instance. */\n pluginId: string;\n };\n /** Short, readable Package name. */\n name: string;\n /** One-sentence, user-facing description of the Package purpose. */\n purpose: string;\n code: {\n /** Plain JavaScript function body that returns the Host-half Cordis Plugin. */\n host?: string;\n /** Plain JavaScript function body that returns the browser Client-half Cordis Plugin. */\n client?: string;\n };\n } & Record;\n /** List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call. */\n cordis_inspect_list: Record;\n /** Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props. */\n cordis_inspect_query: {\n /** Runtime platform that owns the Provider. */\n platform: \"host\" | \"client\";\n /** Exact Provider ID returned by cordis_inspect_list. */\n provider: string;\n /** Exact method name declared by the Provider manifest. */\n method: string;\n /** Optional query input; it must satisfy the method input schema. */\n input?: JsonValue;\n } & Record;\n /** Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers. */\n cordis_inspect_self: {\n /** Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin. */\n pluginId?: string;\n /** Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned. */\n packageId?: string;\n } & Record;\n /** Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it. */\n cordis_run: {\n /** Stable Plugin ID returned by cordis_define. */\n pluginId: string;\n /** Exact immutable Package ID to activate under that Plugin. */\n packageId: string;\n /** Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package. */\n mode: \"run\" | \"update\";\n } & Record;\n /** Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal. */\n cordis_stop: {\n /** Stable dynamic Plugin ID to stop. */\n pluginId: string;\n } & Record;\n /** Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead. */\n cordis_undefine: {\n /** Stable dynamic Plugin ID to remove permanently. */\n pluginId: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops. */\n job_kill: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Optional short reason, recorded in the log and forwarded to the job. */\n reason?: string;\n } & Record;\n /** List your background jobs (running and finished) with their ids, kinds, and statuses. */\n job_list: Record;\n /** Read a background job. Stream jobs return only output since the previous read; final-output jobs 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 job_output: {\n /** Job id returned by the tool that started the background work. */\n job_id: string;\n /** Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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 /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result. */\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 /** Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it. */\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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result. */\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 } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n jobId: 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_define: {\n pluginId: string;\n packageId: string;\n name: string;\n purpose: string;\n hasHostHalf: boolean;\n hasClientHalf: boolean;\n };\n cordis_inspect_list: JsonValue;\n cordis_inspect_query: JsonValue;\n cordis_inspect_self: JsonValue;\n cordis_run: JsonValue;\n cordis_stop: {\n pluginId: string;\n };\n cordis_undefine: {\n pluginId: string;\n wasRunning: boolean;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n job_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n job: {\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 job_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 job_output: {\n text: string;\n job: {\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 ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n jobId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_define","description":"Define an immutable Cordis Package. For a new Plugin, use kind:\"new\" and provide only a semantic prefix of 3–6 lowercase English letters; the Host returns the final pluginId and packageId. To modify an existing Plugin, use kind:\"existing\" with its exact pluginId to append a Package without overwriting older versions. Provide at least one of code.host and code.client. Each value is a plain JavaScript function body that returns a Cordis Plugin; no TypeScript, JSX, or import transformation occurs. Query Inspect before depending on a Service, Event, Builtin, Slot, or token. Define only validates parameters and syntax and records source: it does not request approval, execute apply, or change currentPackageId. On success, call cordis_run with the returned IDs.","parameters":{"type":"object","properties":{"plugin":{"oneOf":[{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"new"},"idPrefix":{"type":"string","description":"Suggested semantic prefix of 3–6 lowercase English letters; the Host adds a unique numeric suffix."}},"required":["kind","idPrefix"]},{"type":"object","additionalProperties":false,"properties":{"kind":{"type":"string","const":"existing"},"pluginId":{"type":"string","description":"Exact ID of an existing Plugin; the new Package is appended to that instance."}},"required":["kind","pluginId"]}]},"name":{"type":"string","description":"Short, readable Package name."},"purpose":{"type":"string","description":"One-sentence, user-facing description of the Package purpose."},"code":{"type":"object","additionalProperties":false,"properties":{"host":{"type":"string","description":"Plain JavaScript function body that returns the Host-half Cordis Plugin."},"client":{"type":"string","description":"Plain JavaScript function body that returns the browser Client-half Cordis Plugin."}}}},"required":["plugin","name","purpose","code"]}},{"name":"cordis_inspect_list","description":"List every Cordis Inspect Provider currently known to the Host, including local Host Providers and the latest manifests synchronized from the Client. Each entry includes its platform, purpose, read-only methods, and input/output schemas. Call this Tool before creating or modifying a Package, then select the provider and method for cordis_inspect_query from its result. Do not guess names or treat an Inspect method as a business Service that Plugin code can call.","parameters":{"type":"object","properties":{}}},{"name":"cordis_inspect_query","description":"Run a read-only query explicitly declared by an Inspect Provider. platform, provider, and method must come from cordis_inspect_list, and input must satisfy that method's schema. Use this Tool before cordis_define to read exact Service methods, Event modes, Builtin signatures, Tool schemas, theme tokens, or live Slot trees and props. Host queries run locally. A Client query waits for the first valid page response and remains pending until a page answers or the Tool is cancelled. This Tool cannot invoke business Service methods or modify the runtime. For Service.listService and Event.listEvents, query without input to navigate the compact signature directory, then query the exact service or event for its structured contract and referenced types. For Slots.listSubTree, query without root to navigate the compact tree, then query the exact root for its complete registration contract and props.","parameters":{"type":"object","properties":{"platform":{"type":"string","description":"Runtime platform that owns the Provider.","enum":["host","client"]},"provider":{"type":"string","description":"Exact Provider ID returned by cordis_inspect_list."},"method":{"type":"string","description":"Exact method name declared by the Provider manifest."},"input":{"description":"Optional query input; it must satisfy the method input schema."}},"required":["platform","provider","method"]}},{"name":"cordis_inspect_self","description":"Inspect dynamic Cordis objects owned by the current Session at increasing levels of detail. With no IDs, list only Plugin summaries. With pluginId alone, return version pointers, the latest Run, and every Package summary. Only pluginId plus packageId returns that immutable Package's Host/Client source and runtime diagnostics. packageId cannot be supplied alone. Query an exact Package before handling @pluginId, repairing an asynchronous failure, or defining an updated version. This Tool is read-only: it neither executes code nor changes version pointers.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define or injected by @pluginId; omit it to list every current Plugin."},"packageId":{"type":"string","description":"Exact immutable Package ID owned by pluginId; when specified, source and diagnostics are returned."}}}},{"name":"cordis_run","description":"Activate one exact Package of a dynamic Plugin. Use mode:\"run\" for the first activation, restarting currentPackageId, or rollback. When current exists, use mode:\"update\" to switch to a different Package, even if the Plugin is currently stopped. An unauthorized Client Package creates an approval request and returns awaiting-approval; an authorized Package returns starting and continues asynchronously in the browser. Neither result waits for the final outcome inside the Tool. currentPackageId changes only after complete success; on failure, the old current and target next remain. Asynchronous success, rejection, or technical failure is reported through state and steering. After a technical failure, read diagnostics with cordis_inspect_self, correct the same Plugin, and retry autonomously. Do not request approval again after the user rejects it.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable Plugin ID returned by cordis_define."},"packageId":{"type":"string","description":"Exact immutable Package ID to activate under that Plugin."},"mode":{"type":"string","description":"Use run for the first activation, restarting current, or rollback; use update to switch from current to a different Package.","enum":["run","update"]}},"required":["pluginId","packageId","mode"]}},{"name":"cordis_stop","description":"Stop the current Run of a dynamic Plugin and cancel unfinished approval or activation requests. Retain the Plugin, every immutable Package, grants, currentPackageId, and nextPackageId so it can later run or update directly. Stopping an already stopped Plugin succeeds idempotently. Use this Tool to disable effects temporarily; use cordis_undefine for permanent removal.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to stop."}},"required":["pluginId"]}},{"name":"cordis_undefine","description":"Permanently remove a dynamic Plugin owned by the current Session. If it is running or awaiting approval, first stop it and cancel the request, then delete every Package, grant, and version pointer. After this returns, its pluginId, packageIds, @ reference, and Package business views are invalid; historical cards retain only a \"Plugin removed\" record. Do not call this Tool when versions must remain available for restart or rollback; use cordis_stop instead.","parameters":{"type":"object","properties":{"pluginId":{"type":"string","description":"Stable dynamic Plugin ID to remove permanently."}},"required":["pluginId"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job 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 job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs 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":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Takes two required arguments: `code`, the BODY of an async function (erasable syntax only; top-level `await` and `return` work), and `description`, a short summary of what the program does. Call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result.","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."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}} -{"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}} -{"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e0351488-7bca-48d6-b7af-87d533858f47"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"9fd9ba62-1b6d-4bb7-98c6-97815e983026"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":19,"time":1783950000018,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}} -{"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}} -{"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6f6c3cc3-350b-4afb-a3d1-8f91b9494628"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} -{"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}} -{"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}}} -{"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"},"isError":false,"content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}]}} -{"type":"tool/code-dispatch-start","seq":27,"time":1786558903189,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"}}} -{"type":"tool/code-dispatch","seq":28,"time":1786558903189,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"},"isError":false,"content":[{"type":"text","text":"{\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n}"}]}} -{"type":"tool/result","seq":29,"time":1786558903193,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"40a17cbf-a853-4813-bbb5-7970cfbc7010"}},"sourceEventSeqs":[24],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":1786558903193,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":31,"time":1786558903203,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}} -{"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}}} -{"type":"assistant/chunk","seq":35,"time":1786558903204,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":36,"time":1786558903204,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":37,"time":1786558903204,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8bdea090-4a9a-451f-bb21-459af50472fa"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"} -{"type":"tool/call","seq":38,"time":1786558903205,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}} -{"type":"tool/result","seq":39,"time":1786558903240,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"c978c208-8ebd-4dd6-b997-606ca7de787e"}},"sourceEventSeqs":[38],"surfaceOp":"append"} -{"type":"step/end","seq":40,"time":1786558903240,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":41,"time":1786558903249,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} -{"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} -{"type":"assistant/chunk","seq":45,"time":1786558903250,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":46,"time":1786558903250,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":47,"time":1786558903250,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e55b2b2e-497c-45d2-8115-16f317ae573f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"} -{"type":"tool/call","seq":48,"time":1786558903250,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool-workflow/run-start","seq":49,"time":1786558903255,"data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","name":"advanced-headless-snapshot"}} -{"type":"tool-workflow/agent-start","seq":50,"time":1786558903485,"data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"label":"workflow-child","phase":"Delegate","childId":"33333333-3333-4333-8333-333333333333"}} -{"type":"tool-workflow/agent-end","seq":51,"time":1786558903518,"data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","seq":1,"outcome":"completed"}} -{"type":"tool-workflow/run-end","seq":52,"time":1786558903521,"data":{"runId":"cd3d2666-94a1-4285-804e-c99630bc7b51","stopReason":"completed"}} -{"type":"tool/result","seq":53,"time":1786558903521,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"f85f58fc-8d7e-4c9f-a0fe-caff480a9fec"}},"sourceEventSeqs":[48],"surfaceOp":"append"} -{"type":"step/end","seq":54,"time":1786558903521,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":55,"time":1786558903530,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":56,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":57,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\":\"snap-1\"}"}}} -{"type":"assistant/chunk","seq":58,"time":1786359174239,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}}} -{"type":"assistant/chunk","seq":59,"time":1786558903530,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":60,"time":1786558903530,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":61,"time":1786558903530,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"68bd993f-a0bd-4ea8-ad16-6b1a19e09bd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"} -{"type":"tool/call","seq":62,"time":1786558903531,"data":{"turn":1,"step":5,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}} -{"type":"tool/result","seq":63,"time":1786558903537,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"2d90e3b5-2a4c-4408-a1a0-3d009786a07b"}},"sourceEventSeqs":[62],"surfaceOp":"append"} -{"type":"step/end","seq":64,"time":1786558903537,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":65,"time":1786558903547,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":66,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":67,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}} -{"type":"assistant/chunk","seq":68,"time":1786359174249,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"assistant/chunk","seq":69,"time":1786558903548,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":70,"time":1786558903548,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":71,"time":1786558903548,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f3823367-2e25-43d2-a129-70dc492b2a90"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"} -{"type":"step/end","seq":72,"time":1786558903549,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":73,"time":1786558903549,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl deleted file mode 100644 index 054472d0ea..0000000000 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl +++ /dev/null @@ -1,75 +0,0 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} -{"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":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: define a host-only dynamic Cordis Package named Snapshot Marker; run and inspect snap-1/pkg-1 through run_code; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; remove snap-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-define","name":"cordis_define","argumentsDelta":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-define","name":"cordis_define","arguments":"{\"plugin\":{\"kind\":\"new\",\"idPrefix\":\"snap\"},\"name\":\"Snapshot Marker\",\"purpose\":\"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\"code\":{\"host\":\"return { apply() {} }\"}}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-define"},"content":[{"type":"tool-result","toolCallId":"advanced-define","content":[{"type":"text","text":"Defined snap-1/pkg-1 (Snapshot Marker); it is not running yet. Use cordis_run to activate this Package."}],"isError":false}],"role":"user","id":"{{sessionId}}"},"meta":{"pluginId":"snap-1","packageId":"pkg-1"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"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":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"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\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"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":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\":\"const run = await tools.cordis_run({ pluginId: 'snap-1', packageId: 'pkg-1', mode: 'run' });\\nconst inspected = await tools.cordis_inspect_self({ pluginId: 'snap-1' });\\nreturn { run, inspected };\",\"description\":\"Run and inspect the dynamic Cordis Package\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":25,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_run","arguments":{"pluginId":"snap-1","packageId":"pkg-1","mode":"run"},"isError":false,"content":[{"type":"text","text":"snap-1/pkg-1 is running (run-1)."}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch-start","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/code-dispatch","seq":28,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:2","name":"cordis_inspect_self","arguments":{"pluginId":"snap-1"},"isError":false,"content":[{"type":"text","text":"{\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":29,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"{\n \"run\": {\n \"status\": \"running\",\n \"pluginId\": \"snap-1\",\n \"packageId\": \"pkg-1\",\n \"pluginRunId\": \"run-1\",\n \"currentPackageId\": \"pkg-1\",\n \"host\": {\n \"status\": \"running\",\n \"provides\": [],\n \"waitingFor\": []\n },\n \"client\": {\n \"status\": \"absent\",\n \"waitingFor\": []\n }\n },\n \"inspected\": {\n \"mode\": \"plugin\",\n \"pluginId\": \"snap-1\",\n \"name\": \"Snapshot Marker\",\n \"packageCount\": 1,\n \"state\": \"running\",\n \"currentPackageId\": \"pkg-1\",\n \"activeRun\": {\n \"pluginRunId\": \"run-1\",\n \"packageId\": \"pkg-1\"\n },\n \"packages\": [\n {\n \"packageId\": \"pkg-1\",\n \"name\": \"Snapshot Marker\",\n \"purpose\": \"Exercise the dynamic Cordis Package lifecycle in the snapshot.\",\n \"hasHostHalf\": true,\n \"hasClientHalf\": false,\n \"isCurrent\": true,\n \"isNext\": false\n }\n ]\n }\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":31,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":36,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":37,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[32,33,34,35,36],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":38,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\",\"run_in_background\":false}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":39,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[38],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":40,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":41,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":46,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":47,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[42,43,44,45,46],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":48,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/run-start","seq":49,"time":0,"data":{"runId":"{{sessionId}}","name":"advanced-headless-snapshot"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/agent-start","seq":50,"time":0,"data":{"runId":"{{sessionId}}","seq":1,"label":"workflow-child","phase":"Delegate","childId":"{{sessionId}}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/agent-end","seq":51,"time":0,"data":{"runId":"{{sessionId}}","seq":1,"outcome":"completed"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool-workflow/run-end","seq":52,"time":0,"data":{"runId":"{{sessionId}}","stopReason":"completed"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":53,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[48],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":54,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":55,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":56,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":57,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-undefine","name":"cordis_undefine","argumentsDelta":"{\"pluginId\":\"snap-1\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":61,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[56,57,58,59,60],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":62,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-undefine","name":"cordis_undefine","arguments":"{\"pluginId\":\"snap-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":63,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-undefine"},"content":[{"type":"tool-result","toolCallId":"advanced-undefine","content":[{"type":"text","text":"Removed dynamic Plugin snap-1 and all of its Packages."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[62],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":64,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":65,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_HEADLESS_OK"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":71,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[66,67,68,69,70],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":72,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":73,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","sessionId":"{{sessionId}}","output":"ADVANCED_HEADLESS_OK","usage":{"inputTokens":18,"outputTokens":18}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/input.json b/examples/headless-agent/tests/snapshots/compaction-recovery/input.json deleted file mode 100644 index 1400d2861c..0000000000 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/input.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "steps": [ - { - "op": "prompt", - "text": "Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED." - } - ] -} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl deleted file mode 100644 index 22afadca55..0000000000 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/session.jsonl +++ /dev/null @@ -1,32 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1786123401613,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"10eb2388-2d40-4564-af27-e7a5419fc14e"}]}} -{"type":"turn/start","seq":1,"time":1786123401614,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1786123401614,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1786123401667,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1786123401667,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"10eb2388-2d40-4564-af27-e7a5419fc14e"},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1786123401667,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786123401668,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job 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 job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs 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":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result.","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."}},"required":["description","prompt"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1786123401669,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786123401680,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"a71b2cfd-c18f-4a1b-82f6-e89fb371a87e"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1786123401680,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}} -{"type":"tool/result","seq":15,"time":1786123401700,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"c9e68608-2dff-44bc-a344-b01006272378"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1786123401700,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1786123401710,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1786123401715,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}} -{"type":"compaction/start","seq":19,"time":1786123401715,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} -{"type":"compaction/summary","seq":20,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":266,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}} -{"type":"user/message","seq":21,"time":1786123401725,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1"},"role":"user","id":"3668b957-07a2-4cb7-96b1-98a23ac8cdb8"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}} -{"type":"compaction/end","seq":22,"time":1786123401725,"data":{"compactionId":"338e88fa-e78b-4d49-bd38-8f919e85f5e1","turn":1}} -{"type":"assistant/chunk","seq":23,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":24,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}} -{"type":"assistant/chunk","seq":25,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}} -{"type":"assistant/chunk","seq":26,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}} -{"type":"assistant/chunk","seq":27,"time":1786123401730,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":28,"time":1786123401730,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bcbfd4ff-60e5-4634-ae39-4de3708a8abc"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"} -{"type":"step/end","seq":29,"time":1786123401730,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":30,"time":1786123401730,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl deleted file mode 100644 index 73e5bbd130..0000000000 --- a/examples/headless-agent/tests/snapshots/compaction-recovery/stream-json.expected.jsonl +++ /dev/null @@ -1,32 +0,0 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} -{"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":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Establish a durable compaction premise before continuing. Record every part of this historical evidence: the snapshot uses keyless replay; persistence uses JSONL; the assembled headless application loads its real Cordis composition; model-visible inputs remain logged; tool calls and results remain paired and ordered; context overflow retains the original failure while recovery is attempted; compaction opens with compaction/start and closes with compaction/end; a successful auxiliary summary records compaction/summary provenance; the replacement surface shadows only an older balanced range; the checkpoint remains smaller than the history it replaces; the newest tool result remains verbatim; the retried request sees that checkpoint; the final response proves the same turn continued; deterministic snapshot evidence stays separate from the live-provider smoke; and no external API key is needed. Emit one alpha marker through bash, then finish the task after any required recovery with the exact words COMPACTION RECOVERED."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Establish a durable compaction premise","messageSeqs":[4],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash","contextWindow":128000}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_compaction_marker","name":"bash","argumentsDelta":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":24,"outputTokens":6}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":24,"outputTokens":6}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_compaction_marker","name":"bash","arguments":"{\"command\":\"printf 'alpha\\n'\",\"description\":\"Emit compaction premise marker\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_compaction_marker"},"content":[{"type":"tool-result","toolCallId":"call_compaction_marker","content":[{"type":"text","text":"alpha\n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"error","failure":{"message":"snapshot request exceeded the model context window","code":"CONTEXT_WINDOW_EXCEEDED"}}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compaction/start","seq":19,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compaction/summary","seq":20,"time":0,"data":{"compactionId":"{{sessionId}}","summary":[{"type":"text","text":"The request established a durable compaction premise."}],"rawOutput":[{"type":"text","text":"The request established a durable compaction premise."}],"llmStreamCall":true,"shadowedRange":{"start":4,"end":4},"shadowedSeqs":[4],"shadowedTokenCount":266,"provider":"deepseek-official","model":"deepseek-v4-flash","maxTokens":32,"usage":{"inputTokens":20,"outputTokens":4}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":21,"time":0,"data":{"content":[{"type":"text","text":"This is an automatically generated checkpoint condensing an earlier span of the conversation to free up context. Treat the captured context as established background and build on it without restating it. Continue the task directly from the messages that follow, without acknowledging this checkpoint.\n\n"},{"type":"text","text":"The request established a durable compaction premise."},{"type":"text","text":""}],"source":{"kind":"plugin","plugin":"compact","compactionId":"{{sessionId}}"},"role":"user","id":"{{sessionId}}"},"sourceEventSeqs":[19,20,4],"surfaceOp":{"op":"replace","start":4,"end":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"compaction/end","seq":22,"time":0,"data":{"compactionId":"{{sessionId}}","turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"COMPACTION RECOVERED"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"COMPACTION RECOVERED"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":4}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"COMPACTION RECOVERED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26,27],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":30,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","sessionId":"{{sessionId}}","output":"COMPACTION RECOVERED","usage":{"inputTokens":44,"outputTokens":10}} diff --git a/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl deleted file mode 100644 index f90f798456..0000000000 --- a/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl +++ /dev/null @@ -1,33 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} -{"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} -{"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} -{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} -{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-prompt-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":11,"outputTokens":3,"cacheReadTokens":2}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} -{"type":"tool/call","seq":19,"time":0,"data":{"turn":1,"step":1,"callId":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}} -{"type":"tool/result","seq":20,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"cli-smoke-call"},"content":[{"type":"tool-result","toolCallId":"cli-smoke-call","content":[{"type":"text","text":"CLI_TOOL_ROUND_TRIP"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[19],"surfaceOp":"append"} -{"type":"step/end","seq":21,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":22,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":23,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"off"},"system":"{{system}}","tools":"{{tools}}"},"reason":"change"}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CLI tool round trip complete: CLI_TOOL_ROUND_TRIP"}],"source":{"kind":"model","provider":"cli-mock","model":"cli-mock"},"id":"{{sessionId}}"},"usage":{"inputTokens":7,"outputTokens":5,"reasoningTokens":1}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":31,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/input.json b/examples/headless-agent/tests/snapshots/pty-tools/input.json deleted file mode 100644 index abb800b56b..0000000000 --- a/examples/headless-agent/tests/snapshots/pty-tools/input.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "steps": [ - { "op": "initialize", "terminalOutput": true }, - { "op": "newSession" }, - { "op": "prompt", "text": "Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE." } - ] -} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl deleted file mode 100644 index 6542087adc..0000000000 --- a/examples/headless-agent/tests/snapshots/pty-tools/session.jsonl +++ /dev/null @@ -1,78 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498587408,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"}]}} -{"type":"turn/start","seq":1,"time":1785821457966,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821457966,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498587436,"data":{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"d35cdacd-b5e6-4968-b7a3-5ec48f403ef7"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730504659,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}]},"role":"user","id":"053af702-9950-4860-913a-3c7e45a54f9d"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730504659,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498587438,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by DeepSeek Harness.\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-observation-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-observation-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 shell/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 job id you start. You are notified in-session when a job finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running job's work. Before giving a final answer, collect every still-relevant job with job_output (set wait: true only when you are genuinely blocked on it), and job_kill jobs that stopped mattering.\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\nUse subagent in the background by default. Start independent delegations together in one assistant message and continue useful work while they run. Set `run_in_background: false` only when your next action depends on that subagent's result. When a background run settles, the runtime sends you a notice containing its outcome and any final assistant message.","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 job id immediately; read its output with `job_output` and stop it with `job_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 job id immediately (collect with job_output, stop with job_kill). No timeout applies."}},"required":["command","description"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"job_kill","description":"Request cancellation of a running background job by job id. Returns immediately; the job settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"job_id":{"type":"string","description":"Job 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 job."}},"required":["job_id"]}},{"name":"job_list","description":"List your background jobs (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"job_output","description":"Read a background job. Stream jobs return only output since the previous read; final-output jobs 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":{"job_id":{"type":"string","description":"Job id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the job reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the job 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":["job_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent returns its result, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. This tool runs in the background by default, immediately returns a durable subagent id, and keeps the child conversation available for later turns. When that run settles, the runtime sends the parent a notice containing its outcome and any final assistant message; `send_message` starts a later turn in the same child conversation. Set `run_in_background: false` only when your next action depends on receiving the result.","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":"Whether to run in the background and return a durable subagent id immediately. Defaults to true. Set false to wait for the result when your next action depends on it."}},"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). 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 its result, not its intermediate steps. This call waits for the subagent and returns its result.","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."}},"required":["description","prompt"]}},{"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 job id for job_output/job_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 job id immediately; collect with job_output or stop with job_kill."}},"required":["sessionId","text"]}},{"name":"terminal_signal","description":"Send an allowed signal to the current foreground process group of a persistent terminal.","parameters":{"type":"object","properties":{"sessionId":{"type":"string","description":"Terminal session id."},"signal":{"type":"string","description":"Signal to deliver. Shell-targeted SIGKILL is rejected; use terminal_close.","enum":["SIGINT","SIGTERM","SIGKILL","SIGTSTP","SIGHUP"]}},"required":["sessionId","signal"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730504660,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498587439,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":13,"time":1785730504661,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":14,"time":1785730504661,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"911213f8-acce-47be-a4f2-9d72ef55d83a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"tool/call","seq":15,"time":1785730504662,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}} -{"type":"tool/result","seq":16,"time":1785730504671,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"2da21b47-7fb3-444c-99a6-2c21743731ee"}},"sourceEventSeqs":[15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":1785730504671,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":18,"time":1785730504679,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-send","name":"terminal_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} -{"type":"assistant/chunk","seq":22,"time":1785498587457,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":23,"time":1785730504680,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":24,"time":1785730504680,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cf12d7ee-322a-4057-97b0-98d828a96f1a"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} -{"type":"tool/call","seq":25,"time":1785730504680,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}} -{"type":"tool/result","seq":26,"time":1785730504688,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"d1ffb2dc-6a33-4c9e-aeb9-b87bf6da8617"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1785730504688,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":28,"time":1785730504696,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"terminal_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} -{"type":"assistant/chunk","seq":32,"time":1785498587473,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":33,"time":1785730504697,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":34,"time":1785730504697,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9b99ae9-4685-4ee5-b951-fbe58f84c4e3"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"} -{"type":"tool/call","seq":35,"time":1785730504697,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}} -{"type":"tool/result","seq":36,"time":1785730504704,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"d0d7331d-0d54-457a-9f7b-beb22abd34e6"}},"sourceEventSeqs":[35],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730504704,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":38,"time":1785730504712,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} -{"type":"assistant/chunk","seq":42,"time":1785498587489,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":43,"time":1785730504713,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":44,"time":1785730504713,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9bb8ec3d-6e6f-44a2-8957-8f2d855f4834"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"} -{"type":"tool/call","seq":45,"time":1785730504713,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}} -{"type":"tool/result","seq":46,"time":1785730504721,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"16fc1ca2-0eca-42d7-85d0-31794424c260"}},"sourceEventSeqs":[45],"surfaceOp":"append"} -{"type":"step/end","seq":47,"time":1785730504721,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":48,"time":1785730504730,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}} -{"type":"assistant/chunk","seq":52,"time":1785498587503,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":53,"time":1785730504731,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":54,"time":1785730504731,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4ebdc957-0369-4bbb-a5a5-4d2ef8ac3493"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"} -{"type":"tool/call","seq":55,"time":1785730504731,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}} -{"type":"tool/result","seq":56,"time":1785730504738,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"55a3e2cc-dbc5-44bf-a824-e3bbc8568cd5"}},"sourceEventSeqs":[55],"surfaceOp":"append"} -{"type":"step/end","seq":57,"time":1785730504738,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":58,"time":1785730504746,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}} -{"type":"assistant/chunk","seq":62,"time":1785498587517,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":63,"time":1785730504747,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":64,"time":1785730504747,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6f956d23-5437-4a75-93a9-3abacd378e07"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"tool/call","seq":65,"time":1785730504747,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}} -{"type":"tool/result","seq":66,"time":1785730504755,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"be12d914-6fe1-4c1e-8262-65b2aa9c20e5"}},"sourceEventSeqs":[65],"surfaceOp":"append"} -{"type":"step/end","seq":67,"time":1785730504755,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":68,"time":1785730504763,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} -{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} -{"type":"assistant/chunk","seq":72,"time":1785498587531,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":73,"time":1785730504764,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":74,"time":1785730504764,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9cca9680-5795-47d8-8edc-f6d44bcaa1ef"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"} -{"type":"step/end","seq":75,"time":1785730504764,"data":{"turn":1,"step":7}} -{"type":"turn/end","seq":76,"time":1785730504764,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl deleted file mode 100644 index 2ef42323fb..0000000000 --- a/examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl +++ /dev/null @@ -1,78 +0,0 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Exercise the six PTY tools in order, including one missing-session signal error, then reply DONE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} -{"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":"user/message","seq":4,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":6,"time":0,"data":{"title":"Exercise the six PTY tools","messageSeqs":[4],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-spawn","name":"terminal_open","argumentsDelta":"{\"type\":\"shell\",\"name\":\"main\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":15,"time":0,"data":{"turn":1,"step":1,"callId":"pty-spawn","name":"terminal_open","arguments":"{\"type\":\"shell\",\"name\":\"main\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"pty-spawn"},"content":[{"type":"tool-result","toolCallId":"pty-spawn","content":[{"type":"text","text":"started terminal session pty-1 (main) [type: shell]\ndsh> "}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[15],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":18,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"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":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-send","name":"terminal_send","argumentsDelta":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"pty-send","name":"terminal_send","arguments":"{\"sessionId\":\"pty-1\",\"text\":\"printf 'PTY_OK\\\\n'\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":26,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"pty-send"},"content":[{"type":"tool-result","toolCallId":"pty-send","content":[{"type":"text","text":"K\ndsh> \n[wait: stdin_read]\n[session: running]\n[output truncated]"}],"isError":false}],"role":"user","id":"{{sessionId}}"},"meta":{"viewport":"printf 'PTY_OK\\n'\nPTY_OK\ndsh> ","waitReason":"stdin_read","sessionStatus":{"kind":"running"},"truncated":false}},"sourceEventSeqs":[25],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":28,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-read","name":"terminal_read","argumentsDelta":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[29,30,31,32,33],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":35,"time":0,"data":{"turn":1,"step":3,"callId":"pty-read","name":"terminal_read","arguments":"{\"sessionId\":\"pty-1\",\"offset\":0,\"count\":20}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"pty-read"},"content":[{"type":"tool-result","toolCallId":"pty-read","content":[{"type":"text","text":"dsh> printf 'PTY_OK\\n'\nPTY_OK\ndsh> \n[lines: 0-3 of 3]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[35],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":37,"time":0,"data":{"turn":1,"step":3}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":38,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-signal","name":"terminal_signal","argumentsDelta":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":44,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[39,40,41,42,43],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":45,"time":0,"data":{"turn":1,"step":4,"callId":"pty-signal","name":"terminal_signal","arguments":"{\"sessionId\":\"pty-missing\",\"signal\":\"SIGINT\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"pty-signal"},"content":[{"type":"tool-result","toolCallId":"pty-signal","content":[{"type":"text","text":"Error: unknown PTY session pty-missing"}],"isError":true}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[45],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":47,"time":0,"data":{"turn":1,"step":4}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":48,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-kill","name":"terminal_close","argumentsDelta":"{\"sessionId\":\"pty-1\"}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":54,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[49,50,51,52,53],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":55,"time":0,"data":{"turn":1,"step":5,"callId":"pty-kill","name":"terminal_close","arguments":"{\"sessionId\":\"pty-1\"}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"pty-kill"},"content":[{"type":"tool-result","toolCallId":"pty-kill","content":[{"type":"text","text":"closed terminal session pty-1"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[55],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":57,"time":0,"data":{"turn":1,"step":5}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":58,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"pty-list","name":"terminal_list","argumentsDelta":"{}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"pty-list","name":"terminal_list","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":65,"time":0,"data":{"turn":1,"step":6,"callId":"pty-list","name":"terminal_list","arguments":"{}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":66,"time":0,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"pty-list"},"content":[{"type":"tool-result","toolCallId":"pty-list","content":[{"type":"text","text":"(no terminal sessions)"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[65],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":67,"time":0,"data":{"turn":1,"step":6}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":68,"time":0,"data":{"turn":1,"step":7}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":73,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":74,"time":0,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[69,70,71,72,73],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":75,"time":0,"data":{"turn":1,"step":7}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":76,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","sessionId":"{{sessionId}}","output":"DONE","usage":{"inputTokens":70,"outputTokens":33}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/input.json b/examples/headless-agent/tests/snapshots/ralph-loop/input.json deleted file mode 100644 index 42652a4ac5..0000000000 --- a/examples/headless-agent/tests/snapshots/ralph-loop/input.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "steps": [ - { - "op": "prompt", - "text": "Run a two-round fresh-agent Ralph loop to prove the shipped headless integration." - } - ] -} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl deleted file mode 100644 index d684022863..0000000000 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.1.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"type":"session","version":0,"id":"42222222-2222-4222-8222-222222222222","createdAt":1783951001000,"cwd":"{{cwd}}","parentSession":"41111111-1111-4111-8111-111111111111"} -{"type":"assistant/chunk","seq":0,"time":1783951001001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":1,"time":1783951001002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-one-report","name":"structured_output","argumentsDelta":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}} -{"type":"assistant/chunk","seq":2,"time":1783951001003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-one-report","name":"structured_output","arguments":"{\"status\":\"continue\",\"summary\":\"ROUND_ONE_HANDOFF\",\"evidence\":[\"Round one inspected the workspace.\"],\"nextSteps\":[\"Finish the snapshot objective.\"],\"blocker\":\"\"}"}}}} -{"type":"assistant/chunk","seq":3,"time":1783951001004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":12}}}} -{"type":"assistant/chunk","seq":4,"time":1783951001005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl deleted file mode 100644 index 15f54b231b..0000000000 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.2.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"type":"session","version":0,"id":"43333333-3333-4333-8333-333333333333","createdAt":1783951002000,"cwd":"{{cwd}}","parentSession":"41111111-1111-4111-8111-111111111111"} -{"type":"assistant/chunk","seq":0,"time":1783951002001,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":1,"time":1783951002002,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"round-two-report","name":"structured_output","argumentsDelta":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}} -{"type":"assistant/chunk","seq":2,"time":1783951002003,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"round-two-report","name":"structured_output","arguments":"{\"status\":\"complete\",\"summary\":\"The Ralph snapshot objective is complete.\",\"evidence\":[\"Two fresh rounds completed through the shipped app.\"],\"nextSteps\":[],\"blocker\":\"\"}"}}}} -{"type":"assistant/chunk","seq":3,"time":1783951002004,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":40,"outputTokens":12}}}} -{"type":"assistant/chunk","seq":4,"time":1783951002005,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl deleted file mode 100644 index 4b6fe2dcb5..0000000000 --- a/examples/headless-agent/tests/snapshots/ralph-loop/session.jsonl +++ /dev/null @@ -1 +0,0 @@ -{"type":"session","version":0,"id":"41111111-1111-4111-8111-111111111111","createdAt":1783951000000,"cwd":"{{cwd}}"} diff --git a/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl b/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl deleted file mode 100644 index fe2c9df583..0000000000 --- a/examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl +++ /dev/null @@ -1,27 +0,0 @@ -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}} -{"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":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run a two-round fresh-agent Ralph loop to prove the shipped headless integration."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Run a two-round fresh-agent Ralph","messageSeqs":[4],"source":{"kind":"fallback"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_ralph","name":"ralph","argumentsDelta":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":8}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":20,"outputTokens":8}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"call_ralph","name":"ralph","arguments":"{\"objective\":\"Prove two fresh Ralph rounds through the shipped headless app.\",\"maxRounds\":2}"}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_ralph"},"content":[{"type":"tool-result","toolCallId":"call_ralph","content":[{"type":"text","text":"Ralph worker reported completion after 2 rounds.\nFinal report:\n{\n \"status\": \"complete\",\n \"summary\": \"The Ralph snapshot objective is complete.\",\n \"evidence\": [\n \"Two fresh rounds completed through the shipped app.\"\n ],\n \"nextSteps\": [],\n \"blocker\": \"\"\n}"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"RALPH SNAPSHOT COMPLETE"}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}} -{"type":"session_event","sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"RALPH SNAPSHOT COMPLETE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[18,19,20,21,22],"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":"turn/end","seq":25,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}} -{"type":"result","sessionId":"{{sessionId}}","output":"RALPH SNAPSHOT COMPLETE","usage":{"inputTokens":50,"outputTokens":12}} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl deleted file mode 100644 index 9d63ab152c..0000000000 --- a/examples/headless-agent/tests/snapshots/subagent-settlement/child.expected.jsonl +++ /dev/null @@ -1,20 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","origin":"subagent","delegationDepth":1} -{"type":"subagent/descriptor","seq":0,"time":0,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Return child result","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} -{"type":"session/end-seed","seq":1,"time":0,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call report."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":3,"time":0,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly CHILD_RESULT and nothing else. Do not call report."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":0,"data":{"title":"Reply with exactly CHILD_RESULT and","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_RESULT"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_RESULT"}}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_RESULT"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":18,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/subagent-settlement/child.replay.jsonl b/examples/headless-agent/tests/snapshots/subagent-settlement/child.replay.jsonl deleted file mode 100644 index 0f7362448a..0000000000 --- a/examples/headless-agent/tests/snapshots/subagent-settlement/child.replay.jsonl +++ /dev/null @@ -1,6 +0,0 @@ -{"type":"session","version":0,"id":"subagent-settlement-child","createdAt":2,"delegationDepth":1} -{"type":"assistant/chunk","seq":0,"time":1,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":1,"time":2,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_RESULT"}}} -{"type":"assistant/chunk","seq":2,"time":3,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_RESULT"}}}} -{"type":"assistant/chunk","seq":3,"time":4,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":4,"time":5,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl b/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl deleted file mode 100644 index 5c47dcdaba..0000000000 --- a/examples/headless-agent/tests/subagent-diagnostic-snapshots/descriptorless-child/parent.expected.jsonl +++ /dev/null @@ -1,31 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Start a background job."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"turn/end","seq":2,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/end-seed","seq":3,"time":0,"data":{}} -{"type":"agent/inbox/spliced","seq":4,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":5,"time":0,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":6,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":7,"time":0,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Call list_agents once and report what it shows."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":0,"data":{"title":"Start a background job.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":11,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"list-once","name":"list_agents","argumentsDelta":"{}"}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":17,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"list-once","name":"list_agents","arguments":"{}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} -{"type":"tool/call","seq":18,"time":0,"data":{"turn":2,"step":1,"callId":"list-once","name":"list_agents","arguments":"{}"}} -{"type":"tool/result","seq":19,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"list-once"},"content":[{"type":"tool-result","toolCallId":"list-once","content":[{"type":"text","text":"{{sessionId}} [diagnostic: corrupt]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[18],"surfaceOp":"append"} -{"type":"step/end","seq":20,"time":0,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":21,"time":0,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The stored subagent is unreadable. PARENT_DONE"}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":27,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The stored subagent is unreadable. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} -{"type":"step/end","seq":28,"time":0,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":29,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts deleted file mode 100644 index 16b4fa7328..0000000000 --- a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Assembled-app regression: a persisted `origin: 'subagent'` child whose log - * carries no descriptor event is surfaced by `list_agents` as a - * `[diagnostic: corrupt]` row instead of being silently dropped. - */ - -import { readFile, readdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { describe, expect, it } from 'vitest' - -const fixtureDir = fileURLToPath(new URL('./subagent-diagnostic-snapshots/descriptorless-child', import.meta.url)) -const replayOverride = join(fixtureDir, 'replay.override.json') -const parentExpected = join(fixtureDir, 'parent.expected.jsonl') -const configPath = fileURLToPath(new URL('../subagent-diagnostic.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const parentId = SessionId('subagent-diagnostic-parent') -const childId = SessionId('subagent-diagnostic-child') -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' -const task = 'Call list_agents once and report what it shows.' - -/** - * Seed a completed parent turn plus one cold child that durably classifies - * as a subagent (`origin`) but never appended its descriptor event — the - * publication-window death the diagnostic row exists for. - */ -async function seedDescriptorlessChild(root: string, cwd: string): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - const parentMeta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: parentId, - createdAt: 1, - cwd, - delegationDepth: 0, - } - const parentEvents: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Start a background job.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, - { type: 'turn/end', seq: 2, time: 12, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - const childMeta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: childId, - createdAt: 2, - cwd, - parentSession: parentId, - origin: 'subagent', - delegationDepth: 1, - } - const childEvents: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 20, data: { turn: 1 } }, - { type: 'turn/end', seq: 1, time: 21, data: { turn: 1, reason: { kind: 'interrupted' } } }, - ] - try { - await ctx.sessionPersistence.create(parentMeta) - await ctx.sessionPersistence.append(parentId, parentEvents) - await ctx.sessionPersistence.create(childMeta) - await ctx.sessionPersistence.append(childId, childEvents) - } finally { - await ctx.fiber.dispose() - } -} - -describe('descriptor-less cold child diagnostic snapshot', () => { - it('surfaces the unreadable child as a corrupt diagnostic through the assembled headless app', async () => { - let cwd = '' - const result = await runLoaderSmoke({ - label: 'subagent diagnostic headless stream-json snapshot', - tempDirPrefix: 'dsh-subagent-diag-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, task], - tsconfigPath, - env: { - DSH_SNAPSHOT_FILE: replayOverride, - DSH_SNAPSHOT_OVERRIDE: replayOverride, - }, - prepare: async (runCwd) => { - cwd = runCwd - await seedDescriptorlessChild(join(runCwd, '.sessions'), runCwd) - }, - inspect: async (runCwd) => { - const sessionsDir = join(runCwd, '.sessions') - const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl')) - const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8'))) - const parent = logs.find(content => content.includes('"subagent-diagnostic-parent"')) - if (parent === undefined) throw new Error('missing persisted parent log') - - // THE model-visible fact: the descriptor-less child is reported, not - // silently dropped, and its reason is the corrupt classification. - expect(parent).toContain(`${childId} [diagnostic: corrupt]`) - - const context: NormalizeContext = { sessionIds: [parentId, childId], cwd } - const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) - if (refreshing) { - await writeFile(parentExpected, normalizedParent) - } - expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8')) - }, - }) - - expect(result.stderr).toBe('') - const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) - expect(records.at(-1)).toMatchObject({ - type: 'result', - sessionId: parentId, - output: 'The stored subagent is unreadable. PARENT_DONE', - }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl deleted file mode 100644 index 1711b58c84..0000000000 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ /dev/null @@ -1,30 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{sessionId}}","origin":"subagent","delegationDepth":1} -{"type":"sandbox/mode","seq":0,"time":0,"data":{"mode":"read-only","source":"delegation"}} -{"type":"agent/inbox/spliced","seq":1,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":2,"time":0,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":4,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} -{"type":"step/start","seq":5,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} -{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":16,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"tool/call","seq":17,"time":0,"data":{"turn":1,"step":1,"callId":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}} -{"type":"tool/result","seq":18,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"child-write"},"content":[{"type":"tool-result","toolCallId":"child-write","content":[{"type":"text","text":"Error: [sandbox: file access denied under read-only mode]\n[sandbox: escalation available — retry this exact operation once with sandbox_permissions (the narrowest wider mode that suffices) + justification; the approval prompt asks the user]"}],"isError":true}],"role":"user","id":"{{sessionId}}"},"error":{"name":"FsError","code":"FS_SANDBOX_DENIED"}},"sourceEventSeqs":[17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":20,"time":0,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} -{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":26,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":0,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":28,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl deleted file mode 100644 index 99947a3797..0000000000 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.replay.jsonl +++ /dev/null @@ -1,18 +0,0 @@ -{"type": "session", "version": 0, "id": "subagent-inheritance-child", "createdAt": 2, "delegationDepth": 1} -{"type":"turn/start","seq":0,"time":1,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":2,"data":{"content":[{"type":"text","text":"delegated task"}],"source":{"kind":"user"}},"surfaceOp":"append"} -{"type":"step/start","seq":2,"time":3,"data":{"turn":1,"step":1}} -{"type":"assistant/chunk","seq":3,"time":4,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":4,"time":5,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"child-write","name":"write","argumentsDelta":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}} -{"type":"assistant/chunk","seq":5,"time":6,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"child-write","name":"write","arguments":"{\"file_path\": \"inherited.txt\", \"content\": \"escaped\"}"}}}} -{"type":"assistant/chunk","seq":6,"time":7,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":7,"time":8,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"step/end","seq":8,"time":9,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":9,"time":10,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":10,"time":11,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":12,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}} -{"type":"assistant/chunk","seq":12,"time":13,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}}}} -{"type":"assistant/chunk","seq":13,"time":14,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":15,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"step/end","seq":15,"time":16,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":16,"time":17,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl deleted file mode 100644 index 0bdcc94938..0000000000 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl +++ /dev/null @@ -1,33 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Tighten this session to read-only."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"sandbox/mode","seq":2,"time":0,"data":{"mode":"read-only"}} -{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/end-seed","seq":4,"time":0,"data":{}} -{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":6,"time":0,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":7,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Delegate the write probe to a subagent."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":11,"time":0,"data":{"title":"Tighten this session to read-only.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":13,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"delegate-write","name":"subagent","argumentsDelta":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":19,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} -{"type":"tool/call","seq":20,"time":0,"data":{"turn":2,"step":1,"callId":"delegate-write","name":"subagent","arguments":"{\"description\": \"Delegated write probe\", \"prompt\": \"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE.\"}"}} -{"type":"tool/result","seq":21,"time":0,"data":{"turn":2,"step":1,"message":{"source":{"kind":"tool","callId":"delegate-write"},"content":[{"type":"tool-result","toolCallId":"delegate-write","content":[{"type":"text","text":"CHILD_DENIED [sandbox: file access denied under read-only mode]"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[20],"surfaceOp":"append"} -{"type":"step/end","seq":22,"time":0,"data":{"turn":2,"step":1}} -{"type":"step/start","seq":23,"time":0,"data":{"turn":2,"step":2}} -{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"text-delta","index":0,"text":"The delegated child was denied by the sandbox. PARENT_DONE"}}} -{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}}}} -{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":2,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":29,"time":0,"data":{"turn":2,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"The delegated child was denied by the sandbox. PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[24,25,26,27,28],"surfaceOp":"append"} -{"type":"step/end","seq":30,"time":0,"data":{"turn":2,"step":2}} -{"type":"turn/end","seq":31,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts deleted file mode 100644 index 0468965368..0000000000 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ /dev/null @@ -1,143 +0,0 @@ -/** - * Assembled-app regression: a parent-only read-only override is seeded into - * its child log and confines a real write under a wider deployment default. - */ - -import { readFile, readdir, writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { SESSION_FORMAT_VERSION, SessionId, type SessionEvent, type SessionHeader } from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { describe, expect, it } from 'vitest' - -const fixtureDir = fileURLToPath(new URL('./subagent-inheritance-snapshots/parent-override', import.meta.url)) -const replayOverride = join(fixtureDir, 'replay.override.json') -const childReplay = join(fixtureDir, 'child.replay.jsonl') -const parentExpected = join(fixtureDir, 'parent.expected.jsonl') -const childExpected = join(fixtureDir, 'child.expected.jsonl') -const configPath = fileURLToPath(new URL('../subagent-inheritance.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const sessionId = SessionId('subagent-inheritance-parent') -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' -const task = 'Delegate the write probe to a subagent.' - -/** Seed a completed parent turn with the only read-only fact in the app. */ -async function seedReadOnlyParent(root: string, cwd: string): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - const meta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 1, - cwd, - delegationDepth: 0, - } - const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, - { type: 'user/message', seq: 1, time: 11, data: createUserMessage({ content: [{ type: 'text', text: 'Tighten this session to read-only.' }], source: { kind: 'user' } }), surfaceOp: 'append' }, - { type: 'sandbox/mode', seq: 2, time: 12, data: { mode: 'read-only' } }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) - } finally { - await ctx.fiber.dispose() - } -} - -describe('parent-only override inheritance snapshot', () => { - it('confines a delegated child through the assembled headless app', async () => { - let cwd = '' - const result = await runLoaderSmoke({ - label: 'subagent inheritance headless stream-json snapshot', - tempDirPrefix: 'dsh-subagent-inherit-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, task], - tsconfigPath, - env: { - // The primary fixture path must exist for llm-replay's config guard; - // the override sidecar fully replaces the derived parent script. - DSH_SNAPSHOT_FILE: replayOverride, - DSH_SNAPSHOT_OVERRIDE: replayOverride, - DSH_SNAPSHOT_CHILD_FILES: childReplay, - }, - prepare: async (runCwd) => { - cwd = runCwd - await seedReadOnlyParent(join(runCwd, '.sessions'), runCwd) - }, - inspect: async (runCwd) => { - // THE physical fact: the child's write never reached the disk. Under - // the deployment default (workspace-write) alone it would succeed. - await expect(readFile(join(runCwd, 'inherited.txt'), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) - - // Collect both persisted logs (parent resumed turn + child run). - const sessionsDir = join(runCwd, '.sessions') - const files = (await readdir(sessionsDir, { recursive: true })).filter(file => file.endsWith('.jsonl')) - const logs = await Promise.all(files.map(async file => readFile(join(sessionsDir, file), 'utf8'))) - const headerOf = (content: string): Record => - JSON.parse(content.split('\n')[0] ?? '{}') as Record - const parent = logs.find(content => content.includes('"subagent-inheritance-parent"')) - const child = logs.find(content => typeof headerOf(content).parentSession === 'string') - if (parent === undefined || child === undefined) throw new Error('missing persisted parent or child log') - - const childRecords = child.trimEnd().split('\n').map( - line => JSON.parse(line) as Record, - ) - expect(childRecords[1]).toMatchObject({ - type: 'sandbox/mode', - seq: 0, - data: { mode: 'read-only', source: 'delegation' }, - }) - - const runtimeContexts = (content: string): string[] => content.trimEnd().split('\n').flatMap((line) => { - const record = JSON.parse(line) as { - type?: string - data?: { source?: { kind?: string; plugin?: string }; content?: Array<{ type?: string; text?: unknown }> } - } - if (record.type !== 'user/message' - || record.data?.source?.kind !== 'plugin' - || record.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return [] - return record.data.content?.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []) ?? [] - }) - const policyContexts = [...runtimeContexts(parent), ...runtimeContexts(child)] - expect(policyContexts).toHaveLength(2) - for (const context of policyContexts) { - expect(context).toContain('Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode.') - expect(context).toContain('Do not refuse a required modification from this policy alone') - expect(context).not.toContain('write and edit tools') - expect(context).not.toContain('one-shot bash commands') - expect(context).not.toContain('terminal sessions') - } - - const context: NormalizeContext = { sessionIds: [sessionId, String(headerOf(child).id)], cwd } - const normalizedParent = scrubRequestHeaders(normalizeSessionLog(parent, context)) - const normalizedChild = scrubRequestHeaders(normalizeSessionLog(child, context)) - if (refreshing) { - await writeFile(parentExpected, normalizedParent) - await writeFile(childExpected, normalizedChild) - } - expect(normalizedParent).toBe(await readFile(parentExpected, 'utf8')) - expect(normalizedChild).toBe(await readFile(childExpected, 'utf8')) - // The child's real write was denied by the real fence. - expect(normalizedChild).toContain('file access denied under read-only mode') - }, - }) - - expect(result.stderr).toBe('') - const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) - expect(records.at(-1)).toMatchObject({ - type: 'result', - sessionId, - output: 'The delegated child was denied by the sandbox. PARENT_DONE', - }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl deleted file mode 100644 index 1d4fccab80..0000000000 --- a/examples/headless-agent/tests/workspace-context-resume-snapshots/offline-edit/session.expected.jsonl +++ /dev/null @@ -1,22 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nOld workspace instruction.\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"ba65bdb41810f4d0129129dcbd6cadcd643c069d"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/end-seed","seq":4,"time":0,"data":{}} -{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":6,"time":0,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":7,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"\nUpdated instructions from: AGENTS.md\n\nThis file changed after it was loaded. Use the following content instead of the previously loaded instructions from this file.\n\nNew workspace instruction after offline edit.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","changes":[{"action":"replace","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"d8375b516f158718bd3463bc8eb7ed42c011b29f"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":11,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}} -{"type":"request/context","seq":13,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":18,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[14,15,16,17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":20,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl b/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl deleted file mode 100644 index e65d26d31a..0000000000 --- a/examples/headless-agent/tests/workspace-context-resume-snapshots/precedence-change/session.expected.jsonl +++ /dev/null @@ -1,22 +0,0 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Remember the workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":2,"time":0,"data":{"content":[{"type":"text","text":"\nThe following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: CLAUDE.md\n\nOld CLAUDE rule.\n\nInstructions from: AGENTS.md\n\nOld AGENTS rule.\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"CLAUDE.md\",\"AGENTS.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"b525eb8a6d3660b732dad4b0aff1b7c63ab32890"},{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"3113bd093ae91976207dcef7390bdc0b2bfcfa10"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"turn/end","seq":3,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"session/end-seed","seq":4,"time":0,"data":{}} -{"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} -{"type":"turn/start","seq":6,"time":0,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":7,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":8,"time":0,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":9,"time":0,"data":{"content":[{"type":"text","text":"Acknowledge the current workspace instruction."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":10,"time":0,"data":{"content":[{"type":"text","text":"\nThis complete workspace instruction baseline replaces all earlier workspace instruction baselines. The following workspace instructions may be relevant to your work. Use them as guidance when applicable. More specific instructions take precedence over broader ones. They do not override system, developer, or direct user instructions.\n\nInstructions from: AGENTS.md\n\nCurrent AGENTS rule.\n\n\nInstructions from: CLAUDE.md\n\nCurrent CLAUDE rule.\n\n"}],"source":{"kind":"agent-instructions","form":"instructions","baseline":true,"baselineIdentity":"{\"projectRoot\":\"\",\"projectRootMarkers\":[\".git\"],\"maxBytes\":65536,\"maxSourceBytes\":1048576,\"instructionFileCandidates\":[\"AGENTS.md\",\"CLAUDE.md\"],\"localInstructionFileCandidates\":[\"AGENTS.local.md\",\"CLAUDE.local.md\"]}","changes":[{"action":"set","scope":".\u0000AGENTS.md","path":"AGENTS.md","digest":"7f53d2327837129750aef117f9754a001c46cf68"},{"action":"set","scope":".\u0000CLAUDE.md","path":"CLAUDE.md","digest":"5b1e9e3fd759eee6b43ceff899e47fb10c64701a"}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":11,"time":0,"data":{"title":"Remember the workspace instruction.","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":12,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}"},"reason":"initial"}} -{"type":"request/context","seq":13,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"RESUME_DONE"}}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"RESUME_DONE"}}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":18,"time":0,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"RESUME_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"}},"sourceEventSeqs":[14,15,16,17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":0,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":20,"time":0,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts deleted file mode 100644 index eb8f027fe5..0000000000 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ /dev/null @@ -1,235 +0,0 @@ -/** - * Assembled-app regression for persisted workspace-instruction resume state. - * @module workspace-context-resume-snapshot - */ - -import { createHash } from 'node:crypto' -import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' -import { dirname, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { Context } from '@deepseek-ai/cordis' -import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' -import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' -import { createUserMessage } from '@deepseek-ai/dsh-llm' -import SessionStore, { - SESSION_FORMAT_VERSION, - SessionId, - type SessionEvent, - type SessionHeader, -} from '@deepseek-ai/dsh-session' -import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' -import { renderWorkspaceContext } from '@deepseek-ai/dsh-agent-instructions' -import { resolveConfig, workspaceBaselineIdentity } from '@deepseek-ai/dsh-agent-instructions/src/config.ts' -import { describe, expect, it } from 'vitest' - -const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') -const replayFixture = join(fixtureDir, 'replay.jsonl') -const replayOverride = join(fixtureDir, 'replay.override.json') -const sessionExpected = join(fixtureDir, 'session.expected.jsonl') -const precedenceExpected = join(dirname(fixtureDir), 'precedence-change/session.expected.jsonl') -const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) -const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) -const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) -const sessionId = SessionId('workspace-context-resume') -const refreshing = process.env.DSH_SNAPSHOT === 'refresh' -const oldInstruction = 'Old workspace instruction.' -const newInstruction = 'New workspace instruction after offline edit.' - -interface SeedBaselineOptions { - files?: Array<{ name: string; content: string }> - instructionFileCandidates?: string[] -} - -async function seedVisibleBaseline( - root: string, - cwd: string, - options: SeedBaselineOptions = {}, -): Promise { - const ctx = new Context() - await ctx.plugin(SessionStore) - await ctx.plugin(JsonlSessionPersistence, { root, compression: 'none' }) - const meta: SessionHeader = { - version: SESSION_FORMAT_VERSION, - id: sessionId, - createdAt: 1, - cwd, - delegationDepth: 0, - } - const files = options.files ?? [{ name: 'AGENTS.md', content: oldInstruction }] - const baseline = renderWorkspaceContext(files.map(file => ({ - absolutePath: join(cwd, file.name), - displayPath: file.name, - content: file.content, - })), { maxBytes: 65536 }) - const config = resolveConfig({ - dshHome: join(cwd, '.dsh'), - maxBytes: 65536, - ...options.instructionFileCandidates === undefined - ? {} - : { instructionFileCandidates: options.instructionFileCandidates }, - }) - const events: SessionEvent[] = [ - { type: 'turn/start', seq: 0, time: 10, data: { turn: 1 } }, - { - type: 'user/message', - seq: 1, - time: 11, - data: createUserMessage({ content: [{ type: 'text', text: 'Remember the workspace instruction.' }], source: { kind: 'user' } }), - surfaceOp: 'append', - }, - { - type: 'user/message', - seq: 2, - time: 12, - data: createUserMessage({ - content: [{ type: 'text', text: baseline.text }], - source: { - kind: 'agent-instructions', - form: 'instructions', - baseline: true, - baselineIdentity: workspaceBaselineIdentity(config, cwd, cwd), - changes: files.map(file => ({ - action: 'set', - scope: `.\0${file.name}`, - path: file.name, - digest: createHash('sha1').update(file.content).digest('hex'), - })), - }, - }), - surfaceOp: 'append', - }, - { type: 'turn/end', seq: 3, time: 13, data: { turn: 1, reason: { kind: 'completed' } } }, - ] - try { - await ctx.sessionPersistence.create(meta) - await ctx.sessionPersistence.append(sessionId, events) - const location = ctx.sessionPersistence.locate(meta) - if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') - return location.path - } finally { - await ctx.fiber.dispose() - } -} - -describe('agent-instructions resume snapshot', () => { - it('appends an offline replacement without duplicating the visible baseline', async () => { - let cwd = '' - let sessionPath = '' - const result = await runLoaderSmoke({ - label: 'agent-instructions resume headless stream-json snapshot', - tempDirPrefix: 'dsh-workspace-context-resume-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, 'Acknowledge the current workspace instruction.'], - tsconfigPath, - env: { - DSH_SNAPSHOT_FILE: replayFixture, - DSH_SNAPSHOT_OVERRIDE: replayOverride, - }, - prepare: async (runCwd) => { - cwd = runCwd - await mkdir(join(runCwd, '.git'), { recursive: true }) - await writeFile(join(runCwd, 'AGENTS.md'), `${newInstruction}\n`) - sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd) - }, - inspect: async () => { - const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } - const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) - if (refreshing) await writeFile(sessionExpected, session) - expect(session).toBe(await readFile(sessionExpected, 'utf8')) - - const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as { - type?: string - data?: { - source?: { kind?: string; baseline?: boolean; changes?: Array> } - content?: Array<{ type?: string; text?: string }> - } - }) - const workspaceEvents = records.filter(record => record.type === 'user/message' - && record.data?.source?.kind === 'agent-instructions') - expect(workspaceEvents.filter(record => record.data?.source?.baseline === true)).toHaveLength(1) - expect(workspaceEvents.filter(record => record.data?.source?.baseline !== true)).toHaveLength(1) - expect(workspaceEvents.at(-1)?.data?.source?.changes).toMatchObject([{ - action: 'replace', scope: '.\0AGENTS.md', path: 'AGENTS.md', - }]) - expect(JSON.stringify(workspaceEvents.at(-1)?.data?.content)).toContain(newInstruction) - - const files = await readdir(join(cwd, '.sessions'), { recursive: true }) - expect(files.filter(file => file.endsWith('.jsonl'))).toHaveLength(1) - }, - }) - - expect(result.stderr).toBe('') - const records = result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record) - expect(records.at(-1)).toMatchObject({ - type: 'result', - sessionId, - output: 'RESUME_DONE', - }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) - - it('supersedes an incompatible baseline when precedence changed offline', async () => { - let cwd = '' - let sessionPath = '' - const result = await runLoaderSmoke({ - label: 'agent-instructions precedence-change resume snapshot', - tempDirPrefix: 'dsh-workspace-context-precedence-', - binScript, - libBinScript: binScript, - configPath, - binArgs: [configPath, 'Acknowledge the current workspace instruction.'], - tsconfigPath, - env: { - DSH_SNAPSHOT_FILE: replayFixture, - DSH_SNAPSHOT_OVERRIDE: replayOverride, - }, - prepare: async (runCwd) => { - cwd = runCwd - await mkdir(join(runCwd, '.git'), { recursive: true }) - await writeFile(join(runCwd, 'AGENTS.md'), 'Current AGENTS rule.\n') - await writeFile(join(runCwd, 'CLAUDE.md'), 'Current CLAUDE rule.\n') - sessionPath = await seedVisibleBaseline(join(runCwd, '.sessions'), runCwd, { - files: [ - { name: 'CLAUDE.md', content: 'Old CLAUDE rule.' }, - { name: 'AGENTS.md', content: 'Old AGENTS rule.' }, - ], - instructionFileCandidates: ['CLAUDE.md', 'AGENTS.md'], - }) - }, - inspect: async () => { - const normalization: NormalizeContext = { sessionIds: [sessionId], cwd } - const session = scrubRequestHeaders(normalizeSessionLog(await readFile(sessionPath, 'utf8'), normalization)) - if (refreshing) { - await mkdir(dirname(precedenceExpected), { recursive: true }) - await writeFile(precedenceExpected, session) - } - expect(session).toBe(await readFile(precedenceExpected, 'utf8')) - - const records = session.trimEnd().split('\n').map(line => JSON.parse(line) as { - type?: string - data?: { - source?: { kind?: string; baseline?: boolean } - content?: Array<{ type?: string; text?: string }> - } - }) - const baselines = records.filter(record => record.type === 'user/message' - && record.data?.source?.kind === 'agent-instructions' - && record.data.source.baseline === true) - expect(baselines).toHaveLength(2) - const replacement = JSON.stringify(baselines.at(-1)?.data?.content) - expect(replacement).toContain('replaces all earlier workspace instruction baselines') - expect(replacement.indexOf('Instructions from: AGENTS.md')) - .toBeLessThan(replacement.indexOf('Instructions from: CLAUDE.md')) - }, - }) - - expect(result.stderr).toBe('') - expect(result.stdout.trimEnd().split('\n').map(line => JSON.parse(line) as Record).at(-1)) - .toMatchObject({ - type: 'result', - sessionId, - output: 'RESUME_DONE', - }) - }, LOADER_SMOKE_TEST_TIMEOUT_MS) -}) diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml deleted file mode 100644 index 78a96ee6ac..0000000000 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md -README.md: ec94fa7ceec5a51585241851eaee2cfcf938738f -README.zh.md: 64a5e607bd37e97b60cb0a4f6ee89a7c766d7464 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md deleted file mode 100644 index ec94fa7cee..0000000000 --- a/examples/jsonrpc-agent/README.md +++ /dev/null @@ -1,40 +0,0 @@ -# jsonrpc-agent - -English | [中文](README.zh.md) - -The unattended coding-agent composition for the Python SDK's bundled JSON-RPC runtime. It intentionally loads no terminal UI, console logger, approval UI, or user-questions tool because stdout belongs to the SDK protocol and turns are driven by the SDK. - -The model-facing tools are: - -- `bash`, foreground only -- `read`, `write`, and `edit` -- `subagent`, using one foreground in-process spawn provider -- `todo_write` - -The surrounding runtime also loads JSONL session persistence and automatic context compaction. `maxTokensAsSuccess` keeps a token-limited model turn as an accepted evaluation result while preserving its `max-tokens` reason. - -## Runtime environment - -| Variable | Purpose | -|---|---| -| `DEEPSEEK_API_KEY` | Credential passed to the OpenAI-compatible host endpoint | -| `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | -| `DSH_CWD` | Agent workspace for bash and filesystem tools | -| `DSH_CONTEXT_WINDOW` | Context capacity recorded for the `DSH_MODEL` catalog entry in the minimal variant | -| `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_MODEL` | Default model used by `minimal.py`; `--model` takes precedence | -| `DSH_SESSION_ROOT` | JSONL session directory | -| `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | - -Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. - -## Minimal variant - -[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. `DSH_SYSTEM_PROMPT` selects its system prompt, with `You are a helpful software engineer assistant.` as the fallback. It suppresses every system-prompt runtime-context contribution for fresh sessions and mounts no context-compaction plugin. Its model-facing tools are exactly: - -- owner-scoped persistent `bash` -- `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` - -It composes the local PTY, bare `fs-local` backend, danger-full-access policy for persistent Bash, and uncompressed JSONL persistence needed by the bundled runtime. Bash and absolute editor paths can modify any path available to the runtime process, so run this variant only against a disposable checkout or container. The persistent PTY requires a POSIX terminal environment and is not a Windows agent interface. - -[`minimal.py`](minimal.py) runs the composition through the Python SDK and uses `DSH_MODEL` as its default model. The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) covers installation, execution, workspace selection, and session identity; the [SDK reference](../../python/sdk/README.md) owns runtime lifecycle and result semantics. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md deleted file mode 100644 index 64a5e607bd..0000000000 --- a/examples/jsonrpc-agent/README.zh.md +++ /dev/null @@ -1,40 +0,0 @@ -# jsonrpc-agent - -[English](README.md) | 中文 - -面向 Python SDK 内置 JSON-RPC 运行时的无人值守编码 agent(智能体)组合。它有意不加载终端 UI、控制台日志记录器、批准界面或用户交互工具,因为 stdout 属于 SDK 协议,轮次由 SDK 驱动。 - -面向模型的工具为: - -- `bash`,仅前台 -- `read`、`write` 和 `edit` -- `subagent`,使用一个在进程内以前台方式运行的 spawn 提供方 -- `todo_write` - -周边运行时还加载 JSONL 会话持久化和自动上下文压缩(context compaction)。`maxTokensAsSuccess` 将受 token 上限限制的模型轮次保留为已接受的评估结果,同时保留其 `max-tokens` 原因。 - -## 运行时环境 - -| 变量 | 用途 | -|---|---| -| `DEEPSEEK_API_KEY` | 传给 OpenAI 兼容宿主端点的凭据 | -| `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | -| `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | -| `DSH_CONTEXT_WINDOW` | 极简变体中为 `DSH_MODEL` 目录项记录的上下文容量 | -| `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_MODEL` | `minimal.py` 使用的默认模型;`--model` 优先 | -| `DSH_SESSION_ROOT` | JSONL 会话目录 | -| `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | - -通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 - -## 极简变体 - -[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。`DSH_SYSTEM_PROMPT` 选择它的系统提示词,未设置时使用 `You are a helpful software engineer assistant.`。它为新建会话抑制每个 system-prompt runtime-context 贡献,且不挂载上下文压缩插件。面向模型的工具严格只有: - -- 所有者作用域内持久化的 `bash` -- 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` - -它组合了内置运行时所需的本地 PTY、裸 `fs-local` 后端、供持久 Bash 使用的 danger-full-access 策略,以及未压缩的 JSONL 持久化。Bash 和编辑器绝对路径可以修改运行时进程有权访问的任何路径,因此只能针对可丢弃的 checkout 或容器运行该变体。持久 PTY 需要 POSIX 终端环境,因此不适用于 Windows agent 接口。 - -[`minimal.py`](minimal.py)通过 Python SDK 运行该组合,并把 `DSH_MODEL` 作为默认模型。[Python SDK 教程](../../docs/user/guide/python-sdk.md)介绍安装、运行、workspace 选择与 session 标识;[SDK 参考](../../python/sdk/README.md)归属运行时生命周期与结果语义。 diff --git a/examples/jsonrpc-agent/cordis.snapshot.yml b/examples/jsonrpc-agent/cordis.snapshot.yml deleted file mode 100644 index 23bc8c5402..0000000000 --- a/examples/jsonrpc-agent/cordis.snapshot.yml +++ /dev/null @@ -1,28 +0,0 @@ -# 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-official` 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: '@deepseek-ai/cordis-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-official - name: DeepSeek - models: - - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/cordis.yml b/examples/jsonrpc-agent/cordis.yml deleted file mode 100644 index 2f7ea46ddf..0000000000 --- a/examples/jsonrpc-agent/cordis.yml +++ /dev/null @@ -1,89 +0,0 @@ -# Unattended coding-agent deployment for the bundled dsh-jsonrpc-agent runtime. -# stdout is reserved for JSON-RPC; do not add a console logger or terminal UI. - -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - config: - maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" - -# The DeepSeek adapter. Shipped default: full thinking at max effort on every -# request; exact-model resolution materializes request defaults before logging. -# The model arrives per session over JSON-RPC, so it is not pinned here. -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - 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: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - timeoutMs: 60000 - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a coding agent.' - workspaceContext: false - skills: - enabled: false - toolBash: - enableRunInBackground: false - toolJobs: false - -- id: sessions - 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' - -- id: subagent - name: '@deepseek-ai/dsh-subagent' - -- id: subagent-spawn-in-process - name: '@deepseek-ai/dsh-subagent-spawn-in-process' - config: - providerName: spawn - -- id: tool-subagent - name: '@deepseek-ai/dsh-tool-subagent' - config: - provider: spawn - toolName: subagent - enableRunInBackground: false - -- id: tool-todo - name: '@deepseek-ai/dsh-tool-todo' - config: - allowParallelInProgress: true - -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: fs-observation-policy - name: '@deepseek-ai/dsh-fs-observation-policy' - -- id: tool-fs - name: '@deepseek-ai/dsh-tool-fs' - -- id: token-meter - name: '@deepseek-ai/dsh-token-meter' - -- id: compaction-basic - name: '@deepseek-ai/dsh-compaction-basic' - config: - thresholdRatio: 0.8 - retainRatio: 0.16 - maxTokens: 8192 - compactionRetries: 1 diff --git a/examples/jsonrpc-agent/minimal.cordis.yml b/examples/jsonrpc-agent/minimal.cordis.yml deleted file mode 100644 index e23d52a866..0000000000 --- a/examples/jsonrpc-agent/minimal.cordis.yml +++ /dev/null @@ -1,82 +0,0 @@ -# Complete unattended minimal-agent composition for the Python SDK. The model -# sees one deployment-selected system prompt and only the owner-scoped -# persistent Bash and string-replace editor tools. Runtime-context injection and -# context compaction are absent. - -- id: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - config: - maxTokensAsSuccess: false - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKeyEnv: DEEPSEEK_API_KEY - streamIdleTimeoutMs: 172800000 - models: - - id: !!js process.env.DSH_MODEL ?? 'deepseek-v4-flash' - contextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-terminal' - -- id: terminal-bash - name: '@deepseek-ai/dsh-terminal-bash' - config: - timeoutMs: 300000 - -# The editor uses the bare local filesystem; persistent Bash still consumes the -# shared danger-full-access sandbox policy above. -- id: fs-local - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - includeRuntimeContext: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolJobs: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - description: |- - Run commands in a bash shell - * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. - * You don't have access to the internet via this tool. - * You do have access to a mirror of common linux and python packages via apt and pip. - * State is persistent across command calls and discussions with the user. - * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. - * Please avoid commands that may produce a very large amount of output. - * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/jsonrpc-agent/minimal.py b/examples/jsonrpc-agent/minimal.py deleted file mode 100644 index e94b02b7d8..0000000000 --- a/examples/jsonrpc-agent/minimal.py +++ /dev/null @@ -1,43 +0,0 @@ -#!/usr/bin/env python3 -"""Run one minimal-agent turn through the bundled Python SDK runtime.""" - -from __future__ import annotations - -import argparse -import os -from pathlib import Path - -from deepseek_harness import DeepSeekHarness - - -CONFIG = Path(__file__).with_name("minimal.cordis.yml") - - -def main() -> None: - """Parse one task and print the agent's final response.""" - parser = argparse.ArgumentParser() - parser.add_argument("prompt", help="Task for the minimal agent") - parser.add_argument("--workspace", type=Path, default=Path.cwd()) - parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) - parser.add_argument("--session-id") - parser.add_argument("--provider", default="deepseek-official") - parser.add_argument("--model", default=os.environ.get("DSH_MODEL", "deepseek-v4-flash")) - parser.add_argument("--max-tokens", type=int) - args = parser.parse_args() - - workspace = args.workspace.resolve() - session_root = args.session_root.resolve() - with DeepSeekHarness( - provider=args.provider, - model=args.model, - max_tokens=args.max_tokens, - cwd=str(workspace), - session_root=str(session_root), - cordis=str(CONFIG.resolve()), - ) as harness: - result = harness.run(args.prompt, session_id=args.session_id) - print(result.final_response) - - -if __name__ == "__main__": - main() diff --git a/examples/jsonrpc-agent/minimal.snapshot.cordis.yml b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml deleted file mode 100644 index 0f26fa6716..0000000000 --- a/examples/jsonrpc-agent/minimal.snapshot.cordis.yml +++ /dev/null @@ -1,20 +0,0 @@ -# Keyless replay keeps the complete minimal composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The replay -# catalog claims the same route initialized by the SDK. -- id: base - name: '@deepseek-ai/cordis-plugin-include' - config: - path: ./minimal.cordis.yml - patches: - - 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-official - name: DeepSeek - models: - - id: deepseek-v4-flash diff --git a/examples/jsonrpc-agent/package.json b/examples/jsonrpc-agent/package.json deleted file mode 100644 index 080b0649a6..0000000000 --- a/examples/jsonrpc-agent/package.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "name": "jsonrpc-agent-example", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Unattended JSON-RPC coding-agent composition" -} 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 deleted file mode 100644 index b693787968..0000000000 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Context } from '@deepseek-ai/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 deleted file mode 100644 index 8a7fc9c6cd..0000000000 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child.cordis.yml +++ /dev/null @@ -1,40 +0,0 @@ -# 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: sdk-jsonrpc-server - name: '@deepseek-ai/dsh-sdk-jsonrpc-server' - -- 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 - toolJobs: 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 deleted file mode 100644 index 9b0a7c7a36..0000000000 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/cordis.yml +++ /dev/null @@ -1,53 +0,0 @@ -# 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: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - agents: - - id: main - provider: mock - model: mock-delegate - cwd: !!js process.cwd() - persona: 'Test SDK subagent cwd inheritance.' - workspaceContext: false - -- id: persistence - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: './.sessions' - compression: 'none' - -- id: checkpoint-policy - name: '@deepseek-ai/dsh-session-checkpoint-policy' 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 deleted file mode 100644 index e0a3664487..0000000000 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { Context } from '@deepseek-ai/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 deleted file mode 100644 index 5420d0afbb..0000000000 --- a/examples/jsonrpc-agent/tests/keyless-smoke.e2e.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { createServer } from 'node:http' -import { mkdtemp, readFile, readdir, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -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)) -const configPath = fileURLToPath(new URL('../cordis.yml', import.meta.url)) -const repoRoot = fileURLToPath(new URL('../../..', import.meta.url)) -const decompress = promisify(zstdDecompress) - -function waitForLine( - lines: string[], - predicate: (value: Record) => boolean, - stderr: () => string, -): Promise> { - return new Promise((resolve, reject) => { - const deadline = Date.now() + 30_000 - const poll = (): void => { - while (lines.length > 0) { - const line = lines.shift()! - if (!line.trim()) continue - try { - const value = JSON.parse(line) as Record - if (predicate(value)) { - resolve(value) - return - } - } catch { - reject(new Error(`non-JSON stdout from JSON-RPC agent runtime: ${line}`)) - return - } - } - if (Date.now() >= deadline) { - reject(new Error(`timed out waiting for JSON-RPC response; stderr=${stderr()}`)) - return - } - setTimeout(poll, 10) - } - poll() - }) -} - -describe('jsonrpc-agent keyless smoke', () => { - it.each([ - { label: 'reports max-token turns with the default mapping config', envValue: undefined }, - { label: 'reports max-token turns with mapping enabled through env', envValue: 'true' }, - { label: 'reports max-token turns with mapping disabled through env', envValue: 'false' }, - ])('$label', async ({ envValue }) => { - const root = await mkdtemp(join(tmpdir(), 'dsh-jsonrpc-agent-smoke-')) - const modelRequests: Record[] = [] - const modelServer = createServer((request, response) => { - let body = '' - request.setEncoding('utf8') - request.on('data', (chunk: string) => { body += chunk }) - request.on('end', () => { - modelRequests.push(JSON.parse(body) as Record) - response.writeHead(200, { 'content-type': 'text/event-stream' }) - response.write('data: {"choices":[{"delta":{"role":"assistant","content":null}}]}\n\n') - response.write('data: {"choices":[{"delta":{"content":"done"}}]}\n\n') - response.write('data: {"choices":[{"delta":{},"finish_reason":"length"}],"usage":{"prompt_tokens":3,"completion_tokens":1}}\n\n') - response.end('data: [DONE]\n\n') - }) - }) - 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') - // 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, - configPath, - ], { - cwd: repoRoot, - 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 }), - }, - timeout: 35_000, - killSignal: 'SIGKILL', - reject: false, - }) - const lines: string[] = [] - let stdoutBuffer = '' - let stderr = '' - child.stdout.on('data', (chunk: Buffer) => { - stdoutBuffer += chunk.toString('utf8') - const parts = stdoutBuffer.split('\n') - stdoutBuffer = parts.pop() ?? '' - lines.push(...parts) - }) - child.stderr.on('data', (chunk: Buffer) => { stderr += chunk.toString('utf8') }) - - try { - child.stdin.write(`${JSON.stringify({ - jsonrpc: '2.0', - id: 1, - method: 'initialize', - params: { cwd: root, provider: 'deepseek-official', model: 'deepseek-v4-pro', maxTokens: 1234 }, - })}\n`) - const initialized = await waitForLine(lines, value => value.id === 1, () => stderr) - expect(initialized).toMatchObject({ - jsonrpc: '2.0', - id: 1, - result: { serverInfo: { name: 'deepseek-harness-sdk-runtime' } }, - }) - - child.stdin.write(`${JSON.stringify({ - jsonrpc: '2.0', - id: 2, - method: 'session/prompt', - params: { sessionId: 'main', contentBlocks: [{ type: 'text', text: 'inspect tools' }] }, - })}\n`) - const prompt = await waitForLine(lines, value => value.id === 2, () => stderr) - expect(prompt).toMatchObject({ - jsonrpc: '2.0', - id: 2, - result: { messageId: expect.any(String) as unknown }, - }) - const turnEnd = await waitForLine(lines, (value) => { - if (value.method !== 'session.event') return false - const params = value.params as Record | undefined - const event = params?.event as Record | undefined - return params?.sessionId === 'main' && event?.type === 'turn/end' - }, () => stderr) - expect(turnEnd).toMatchObject({ - jsonrpc: '2.0', - method: 'session.event', - params: { - sessionId: 'main', - event: { - type: 'turn/end', - data: { reason: { kind: 'max-tokens' } }, - }, - }, - }) - const tools = modelRequests[0]?.tools as { function?: { name?: string } }[] - expect(modelRequests[0]?.max_tokens).toBe(1234) - expect(tools.map(tool => tool.function?.name).sort()).toEqual([ - 'bash', - 'edit', - 'read', - 'subagent', - 'todo_write', - 'write', - ]) - - 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: {} }) - 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')) - expect(log).toBeDefined() - const compressed = await readFile(join(sessionsRoot, log!)) - expect(compressed.subarray(0, 4).toString('hex')).toBe('28b52ffd') - expect(JSON.parse((await decompress(compressed)).toString())).toMatchObject({ type: 'session', id: 'main' }) - } finally { - // 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 { exitCode, stdout, stderr } = await execa(process.execPath, [ - '--import', - 'tsx', - binScript, - configPath, - ], { - cwd: repoRoot, - env: { - DEEPSEEK_API_KEY: 'keyless-smoke-no-call', - DSH_MAX_TOKENS_AS_SUCCESS: 'sometimes', - }, - stdin: 'ignore', - timeout: 25_000, - killSignal: 'SIGKILL', - reject: false, - }) - - expect(exitCode, stderr).toBe(1) - expect(stdout).toBe('') - expect(stderr).toContain('plugin tree failed to load') - expect(stderr).toContain('failed to apply loader entry sdk-jsonrpc-server (@deepseek-ai/dsh-sdk-jsonrpc-server)') - expect(stderr).toContain('sometimes') - }, 30_000) -}) diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts deleted file mode 100644 index 9b45292df5..0000000000 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ /dev/null @@ -1,466 +0,0 @@ -/** - * 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 the SDK `RunResult`, 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 { existsSync } from 'node:fs' -import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { basename, delimiter, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { describe, expect, it } from 'vitest' -import { - normalizeSessionLog, - normalizeStdout, - refreshFixtureReplacements, - scrubRequestHeaders, - stabilizeFixtureMessageIds, - stabilizeRefreshLog, - tokenizeSessionFixtureCwd, - type HarvestedLog, - type NormalizeContext, -} from '@deepseek-ai/dsh-acp-snapshot' -import { resolveExampleLaunch } from '@deepseek-ai/dsh-loader-smoke' -import { DeepSeekHarness, type HarnessNotification, type RunResult } 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 minimalLiveConfig = join(testsDir, '..', 'minimal.cordis.yml') -const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.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 MINIMAL_SYSTEM_PROMPT = 'You are the environment-selected minimal software engineer.' -const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell -* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. -* You don't have access to the internet via this tool. -* You do have access to a mirror of common linux and python packages via apt and pip. -* State is persistent across command calls and discussions with the user. -* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. -* Please avoid commands that may produce a very large amount of output. -* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` - -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 - /** Optional scenario-specific live and replay compositions. */ - configs?: { live: string; replay: string } - /** Environment overrides passed to the runtime subprocess. */ - environment?: Readonly> - /** Cwd-relative files whose final contents are part of the scenario contract. */ - expectedFiles?: Readonly> - /** Assembled model-facing tool names and required argument keys. */ - expectedTools?: Readonly> - /** Exact assembled system prompt for the root request. */ - expectedSystem?: string - /** Exact model-facing descriptions for selected tools. */ - expectedToolDescriptions?: Readonly> - /** Expected runtime-context state in the real assembled request. */ - runtimeContext?: false | { includes: readonly string[]; excludes: readonly string[] } -} - -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-in-process', - 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, - }, - { - name: 'persistent-tools', - prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', - sessionId: 'persistent-tools-snapshot', - children: 0, - configs: { live: minimalLiveConfig, replay: minimalReplayConfig }, - environment: { DSH_SYSTEM_PROMPT: MINIMAL_SYSTEM_PROMPT }, - expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, - expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, - expectedSystem: MINIMAL_SYSTEM_PROMPT, - expectedToolDescriptions: { bash: MINIMAL_BASH_DESCRIPTION }, - runtimeContext: false, - }, -] - -interface PersistedLog { - readonly path: string - readonly content: string - readonly header: Record -} - -interface MissingFile { - readonly missing: true -} - -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 } - })) -} - -interface LoggedRequestHeader { - type?: string - data?: { header?: { system?: unknown; tools?: LoggedTool[] } } -} - -interface LoggedTool { - readonly name: string - readonly description?: unknown - readonly parameters: { readonly required?: string[] } -} - -function assembledTools(log: PersistedLog): LoggedTool[] { - const event = log.content.trimEnd().split('\n') - .map(line => JSON.parse(line) as LoggedRequestHeader) - .find(candidate => candidate.type === 'request/header') - const tools = event?.data?.header?.tools - if (tools === undefined) throw new Error('session log has no request/header tools') - return tools -} - -function assembledToolRequirements(log: PersistedLog): Record { - return Object.fromEntries(assembledTools(log).map(tool => [tool.name, tool.parameters.required ?? []])) -} - -function assembledToolDescriptions(log: PersistedLog): Record { - return Object.fromEntries(assembledTools(log).map((tool) => { - if (typeof tool.description !== 'string') throw new Error(`tool ${tool.name} has no description`) - return [tool.name, tool.description] - })) -} - -function assembledSystem(log: PersistedLog): string { - const event = log.content.trimEnd().split('\n') - .map(line => JSON.parse(line) as LoggedRequestHeader) - .find(candidate => candidate.type === 'request/header') - const system = event?.data?.header?.system - if (typeof system !== 'string') throw new Error('session log has no request/header system') - return system -} - -function assembledRuntimeContexts(log: PersistedLog): string[] { - return log.content.trimEnd().split('\n').flatMap((line) => { - const event = JSON.parse(line) as { - type?: string - data?: { source?: { kind?: string; plugin?: string }; content?: Array<{ type?: string; text?: unknown }> } - } - if (event.type !== 'user/message' - || event.data?.source?.kind !== 'plugin' - || event.data.source.plugin !== '@deepseek-ai/dsh-system-prompt') return [] - return event.data.content?.flatMap(block => block.type === 'text' && typeof block.text === 'string' ? [block.text] : []) ?? [] - }) -} - -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', - } -} - -async function hydrateReplayFixtures(scenario: SdkScenario, cwd: string): Promise { - const root = join(cwd, '.replay-fixtures') - await mkdir(root, { recursive: true }) - return Promise.all(fixtureFiles(scenario).map(async (source) => { - const destination = join(root, basename(source)) - await writeFile(destination, (await readFile(source, 'utf8')).replaceAll('{{cwd}}', cwd)) - return destination - })) -} - -async function readExpectedFile(path: string): Promise { - try { - return await readFile(path, 'utf8') - } catch (error: unknown) { - if (error instanceof Error && (error as NodeJS.ErrnoException).code === 'ENOENT') return { missing: true } - throw error - } -} - -/** - * 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 owned-run projection. */ -function normalizeResult(result: RunResult, ctx: NormalizeContext): string { - return normalizeStdout(`${JSON.stringify({ - sessionId: result.sessionId, - finalResponse: result.finalResponse, - })}\n`, ctx) -} - -/** One SDK turn against a fresh runtime subprocess in an isolated cwd. */ -async function runScenario(scenario: SdkScenario): Promise<{ - result: RunResult - notifications: HarnessNotification[] - logs: PersistedLog[] - observedFiles: Record - cwd: string -}> { - const cwd = await mkdtemp(join(tmpdir(), `sdk-snapshot-${scenario.name}-`)) - const sessionsRoot = join(cwd, '.sessions') - const replayFixtures = recording ? [] : await hydrateReplayFixtures(scenario, cwd) - const launch = resolveExampleLaunch({ - srcBin: runtimeBin, - configArgs: [], - tsconfigPath: repoTsconfig, - }) - const [parentFixture, ...childFixtures] = replayFixtures - 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 - ? scenario.configs?.live ?? liveConfig - : scenario.configs?.replay ?? replayConfig, - DSH_SESSION_ROOT: sessionsRoot, - DSH_CWD: cwd, - DSH_SNAPSHOT: mode, - NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '), - ...parentFixture === undefined ? {} : { - DSH_SNAPSHOT_FILE: parentFixture, - ...childFixtures.length > 0 ? { DSH_SNAPSHOT_CHILD_FILES: childFixtures.join(delimiter) } : {}, - }, - ...scenario.environment, - } - - const harness = new DeepSeekHarness({ - launch: { - command: launch.command, - args: launch.args, - cwd, - env, - requestTimeoutMs: 110_000, - }, - cwd, - provider: 'deepseek-official', - model: 'deepseek-v4-flash', - }) - try { - const notifications: HarnessNotification[] = [] - const result = await harness.run(scenario.prompt.replaceAll('{{cwd}}', cwd), { - sessionId: scenario.sessionId, - onNotification: (notification) => { notifications.push(notification) }, - }) - await harness.close() - const logs = await persistedLogs(sessionsRoot) - const observedFiles = Object.fromEntries(await Promise.all( - Object.keys(scenario.expectedFiles ?? {}).map(async (path): Promise<[string, string | MissingFile]> => [ - path, - await readExpectedFile(join(cwd, path)), - ]), - )) - return { result, notifications, logs, observedFiles, 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, observedFiles, cwd } = await runScenario(scenario) - const ordered = orderLogs(logs, scenario) - const actualContext = contextOf(ordered, cwd) - const files = fixtureFiles(scenario) - - 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 }) - const existing = await Promise.all(files.map(async file => existsSync(file) ? readFile(file, 'utf8') : '')) - const fixtures = stabilizeFixtureMessageIds( - ordered.map(log => scrubRequestHeaders(tokenizeSessionFixtureCwd(log.content))), - existing, - ) - await Promise.all(fixtures.map(async (fixture, index) => { - const file = files[index] - if (file === undefined) throw new Error(`no fixture path for persisted log ${index}`) - await writeFile(file, fixture) - })) - } - - 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) - const refreshed = ordered.map((log, index) => { - const existing = expectedContents[index] - if (existing === undefined) throw new Error(`no fixture for persisted log ${index}`) - return scrubRequestHeaders(tokenizeSessionFixtureCwd( - stabilizeRefreshLog(log.content, existing, replacements, actualContext), - )) - }) - expectedContents = stabilizeFixtureMessageIds(refreshed, expectedContents) - await Promise.all(expectedContents.map(async (stable, index) => { - const file = files[index] - if (file === undefined) throw new Error(`no fixture for persisted log ${index}`) - await writeFile(file, stable) - })) - } - - for (const [index, expected] of expectedContents.entries()) { - expect(scrubRequestHeaders(expected), `${scenario.name} session fixture ${index} carries request-header bulk`) - .toBe(expected) - } - - // 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(notifications.at(-1)).toMatchObject({ - method: 'session.status', - params: { status: 'idle' }, - }) - expect(observedFiles).toEqual(scenario.expectedFiles ?? {}) - if (scenario.expectedTools !== undefined) { - const parent = ordered[0] - if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) - expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) - } - if (scenario.expectedSystem !== undefined) { - const parent = ordered[0] - if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) - expect(assembledSystem(parent)).toBe(scenario.expectedSystem) - } - if (scenario.expectedToolDescriptions !== undefined) { - const parent = ordered[0] - if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) - expect(assembledToolDescriptions(parent)).toMatchObject(scenario.expectedToolDescriptions) - } - if (scenario.runtimeContext !== undefined) { - const parent = ordered[0] - if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) - const contexts = assembledRuntimeContexts(parent) - if (scenario.runtimeContext === false) { - expect(contexts).toEqual([]) - } else { - expect(contexts).toHaveLength(1) - const context = contexts[0] as string - for (const clause of scenario.runtimeContext.includes) expect(context).toContain(clause) - for (const clause of scenario.runtimeContext.excludes) expect(context).not.toContain(clause) - const system = assembledSystem(parent) - for (const clause of scenario.runtimeContext.includes) expect(system).not.toContain(clause) - } - } - 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 deleted file mode 100644 index 4f2601b62c..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl +++ /dev/null @@ -1,101 +0,0 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"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"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"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":"user/message","seq":4,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Run this exact command with","messageSeqs":[4],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":17,"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":18,"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":19,"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":20,"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":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":" its"}}}}} -{"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":" stdout"}}}}} -{"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":" only"}}}}} -{"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":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"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":27,"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":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":"command"}}}}} -{"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":"\""}}}}} -{"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":": "}}}}} -{"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":"\""}}}}} -{"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":"echo"}}}}} -{"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":" d"}}}}} -{"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":"sh"}}}}} -{"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":"-s"}}}}} -{"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":"dk"}}}}} -{"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":"-proof"}}}}} -{"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":"739"}}}}} -{"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":"1"}}}}} -{"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":"\""}}}}} -{"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":"description"}}}}} -{"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":"\""}}}}} -{"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":": "}}}}} -{"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":"\""}}}}} -{"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":"Run"}}}}} -{"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":" the"}}}}} -{"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":" echo"}}}}} -{"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":" command"}}}}} -{"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":" as"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":55,"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":56,"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":57,"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":58,"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":59,"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":60,"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":61,"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":62,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":63,"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":64,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Ry17evSfTr0uJnHhg3X93070"},"content":[{"type":"tool-result","toolCallId":"call_00_Ry17evSfTr0uJnHhg3X93070","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[63],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":66,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":67,"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":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":" command"}}}}} -{"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":" produced"}}}}} -{"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":" the"}}}}} -{"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":" expected"}}}}} -{"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":" output"}}}}} -{"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":"."}}}}} -{"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":" I"}}}}} -{"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":"'ll"}}}}} -{"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":" reply"}}}}} -{"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":" with"}}}}} -{"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":" just"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":80,"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":81,"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":82,"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":83,"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":84,"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":85,"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":86,"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":87,"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":88,"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":89,"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":90,"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":91,"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":92,"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":93,"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":94,"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":95,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":96,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."},{"type":"text","text":"dsh-sdk-proof-7391"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":97,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":98,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl deleted file mode 100644 index c2ea52eb1b..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl +++ /dev/null @@ -1,33 +0,0 @@ -{"type":"session","version":0,"id":"sdk-snapshot-bash","createdAt":1785097395899,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498589606,"data":{"target":"next-turn","start":0,"inserted":[{"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"},"role":"user","id":"8ef0b6e2-40ab-430b-b4df-6514323c7270"}]}} -{"type":"turn/start","seq":1,"time":1785821460035,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821460035,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1785097395908,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498589630,"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"},"role":"user","id":"8ef0b6e2-40ab-430b-b4df-6514323c7270"},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1785498589630,"data":{"title":"Run this exact command with","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498589632,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785730506490,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1785097396657,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1785097396679,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,1,24,25,0,0,25,1,24,1,0,75,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," specific"," bash"," command"," and"," reply"," with"," its"," stdout"," only","."]}} -{"type":"assistant/chunk","seq":26,"time":1785097396857,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":27,"time0":1785097396857,"data":{"turn":1,"step":1,"index":1,"dt":[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,55,0],"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":58,"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":59,"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":60,"time":1785498589644,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":61,"time":1785730506499,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":62,"time":1785730506500,"data":{"turn":1,"step":1,"message":{"role":"assistant","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\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f899e1ce-0802-4305-b2ff-295c858ba09c"},"usage":{"inputTokens":123,"outputTokens":89,"cacheReadTokens":1664,"reasoningTokens":17}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61],"surfaceOp":"append"} -{"type":"tool/call","seq":63,"time":1785730506500,"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":64,"time":1785730506517,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_Ry17evSfTr0uJnHhg3X93070"},"content":[{"type":"tool-result","toolCallId":"call_00_Ry17evSfTr0uJnHhg3X93070","content":[{"type":"text","text":"dsh-sdk-proof-7391\n"}],"isError":false}],"role":"user","id":"9de11dc6-2548-440a-bed2-a89f9779d2da"}},"sourceEventSeqs":[63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":1785730506517,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":66,"time":1785730506526,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":67,"time":1785097398255,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":68,"time0":1785097398280,"data":{"turn":1,"step":2,"index":0,"dt":[1,0,24,1,0,0,25,0,0,26,1,0,0,0],"texts":["The"," command"," produced"," the"," expected"," output","."," I","'ll"," reply"," with"," just"," that"," stdout","."]}} -{"type":"assistant/chunk","seq":83,"time":1785097398358,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":84,"time0":1785097398382,"data":{"turn":1,"step":2,"index":1,"dt":[0,0,0,1,0,25,0],"texts":["d","sh","-s","dk","-proof","-","739","1"]}} -{"type":"assistant/chunk","seq":92,"time":1785097398409,"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":93,"time":1785097398409,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"dsh-sdk-proof-7391"}}}} -{"type":"assistant/chunk","seq":94,"time":1785498589681,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}}}} -{"type":"assistant/chunk","seq":95,"time":1785730506530,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":96,"time":1785730506530,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command produced the expected output. I'll reply with just that stdout."},{"type":"text","text":"dsh-sdk-proof-7391"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"54a3c713-55c2-4e95-9437-e7e3680b18ae"},"usage":{"inputTokens":233,"outputTokens":24,"cacheReadTokens":1664,"reasoningTokens":15}},"sourceEventSeqs":[67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"} -{"type":"step/end","seq":97,"time":1785730506531,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":98,"time":1785730506531,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl deleted file mode 100644 index 550d3495f9..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl +++ /dev/null @@ -1,78 +0,0 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"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":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Prove that bash state persists.","messageSeqs":[4],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"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":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":23,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":24,"time":0,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[24],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":58,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":63,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[58,59,60,61,62],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":64,"time":0,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":65,"time":0,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[64],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":66,"time":0,"data":{"turn":1,"step":6}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":67,"time":0,"data":{"turn":1,"step":7}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":68,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":69,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":70,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":71,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":72,"time":0,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":73,"time":0,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[68,69,70,71,72],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":74,"time":0,"data":{"turn":1,"step":7}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":75,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl deleted file mode 100644 index 77fe6f03a6..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl +++ /dev/null @@ -1,77 +0,0 @@ -{"type":"session","version":0,"id":"persistent-tools-snapshot","createdAt":1785331618309,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498592367,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"9a08e199-69d7-4b85-bfa4-27b41a92672a"}]}} -{"type":"turn/start","seq":1,"time":1785821461907,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821461907,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1785331618312,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498592368,"data":{"content":[{"type":"text","text":"Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9."}],"source":{"kind":"user"},"role":"user","id":"9a08e199-69d7-4b85-bfa4-27b41a92672a"},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1785498592368,"data":{"title":"Prove that bash state persists.","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1786547087174,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1786547087175,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1786547087175,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":9,"time":1786547087175,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-1","name":"bash","argumentsDelta":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} -{"type":"assistant/chunk","seq":10,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} -{"type":"assistant/chunk","seq":11,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":12,"time":1785331618326,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1786547087175,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0d064526-8eff-482d-8525-ac478e1d1791"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} -{"type":"tool/call","seq":14,"time":1786547087176,"data":{"turn":1,"step":1,"callId":"bash-1","name":"bash","arguments":"{\"command\":\"cd /tmp && export DSH_EXAMPLE_COUNT=1 && printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} -{"type":"tool/result","seq":15,"time":1786547094340,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"bash-1"},"content":[{"type":"tool-result","toolCallId":"bash-1","content":[{"type":"text","text":"COUNT=1 CWD=/tmp"}],"isError":false}],"role":"user","id":"2c01f81a-01ea-47e2-bf92-f7825b7cc69f"}},"sourceEventSeqs":[14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1786547094340,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":17,"time":1786547094341,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":18,"time":1786547094341,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":19,"time":1786547094341,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-2","name":"bash","argumentsDelta":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}} -{"type":"assistant/chunk","seq":20,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}}}} -{"type":"assistant/chunk","seq":21,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":22,"time":1785331618652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1786547094341,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4078ce-0e18-419a-b720-339918aecf26"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} -{"type":"tool/call","seq":24,"time":1786547094341,"data":{"turn":1,"step":2,"callId":"bash-2","name":"bash","arguments":"{\"command\":\"DSH_EXAMPLE_COUNT=$((DSH_EXAMPLE_COUNT + 1)); printf \\\"COUNT=%s CWD=%s\\\\n\\\" \\\"$DSH_EXAMPLE_COUNT\\\" \\\"$PWD\\\"\"}"}} -{"type":"tool/result","seq":25,"time":1786547097878,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"bash-2"},"content":[{"type":"tool-result","toolCallId":"bash-2","content":[{"type":"text","text":"COUNT=2 CWD=/tmp"}],"isError":false}],"role":"user","id":"6e3ad5e1-1149-44d5-bd20-d9cc0139c747"}},"sourceEventSeqs":[24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1786547097878,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":1786547097878,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":1786547097878,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":1786547097878,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-create","name":"str_replace_editor","argumentsDelta":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}} -{"type":"assistant/chunk","seq":30,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}}}} -{"type":"assistant/chunk","seq":31,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":32,"time":1785331618762,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":1786547097878,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e5769b2d-ea91-42fe-a78f-2f7f408f545e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":1786547097879,"data":{"turn":1,"step":3,"callId":"editor-create","name":"str_replace_editor","arguments":"{\"command\":\"create\",\"path\":\"{{cwd}}/note.txt\",\"file_text\":\"target:\\n\\told\\n\"}"}} -{"type":"tool/result","seq":35,"time":1786547097885,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"editor-create"},"content":[{"type":"tool-result","toolCallId":"editor-create","content":[{"type":"text","text":"New file created successfully at: {{cwd}}/note.txt"}],"isError":false}],"role":"user","id":"af41060c-7007-4ada-89d6-8b15a0e8be7c"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1786547097885,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":37,"time":1786547097885,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":1786547097886,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":1786547097886,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-view","name":"str_replace_editor","argumentsDelta":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}} -{"type":"assistant/chunk","seq":40,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}}}} -{"type":"assistant/chunk","seq":41,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":42,"time":1785331618784,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":1786547097886,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0e9afabb-10a6-444c-ae22-fcdbb5e14695"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":1786547097886,"data":{"turn":1,"step":4,"callId":"editor-view","name":"str_replace_editor","arguments":"{\"command\":\"view\",\"path\":\"{{cwd}}/note.txt\"}"}} -{"type":"tool/result","seq":45,"time":1786547097887,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"editor-view"},"content":[{"type":"tool-result","toolCallId":"editor-view","content":[{"type":"text","text":"Here's the content of {{cwd}}/note.txt with line numbers (which has a total of 3 lines):\n 1 target:\n 2 \told\n 3 \n"}],"isError":false}],"role":"user","id":"a4472b37-6311-4880-bce2-cc369f9bc34b"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":1786547097887,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":1786547097887,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":1786547097887,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":1786547097887,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"editor-replace","name":"str_replace_editor","argumentsDelta":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}} -{"type":"assistant/chunk","seq":50,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":52,"time":1785331618801,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":1786547097887,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba189070-d46e-461e-969b-9bca032bb154"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":1786547097887,"data":{"turn":1,"step":5,"callId":"editor-replace","name":"str_replace_editor","arguments":"{\"command\":\"str_replace\",\"path\":\"{{cwd}}/note.txt\",\"old_str\":\"\\told\",\"new_str\":\"\\tnew\"}"}} -{"type":"tool/result","seq":55,"time":1786547097892,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"editor-replace"},"content":[{"type":"tool-result","toolCallId":"editor-replace","content":[{"type":"text","text":"The file {{cwd}}/note.txt has been edited successfully."}],"isError":false}],"role":"user","id":"17331db9-174b-4699-9c9e-3140921956c4"}},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":1786547097892,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":57,"time":1786547097892,"data":{"turn":1,"step":6}} -{"type":"assistant/chunk","seq":58,"time":1786547097893,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":59,"time":1786547097893,"data":{"turn":1,"step":6,"chunk":{"type":"tool-call-delta","index":0,"id":"bash-exit","name":"bash","argumentsDelta":"{\"command\":\"exit 9\"}"}}} -{"type":"assistant/chunk","seq":60,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}}}} -{"type":"assistant/chunk","seq":61,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":62,"time":1785331618804,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":63,"time":1786547097893,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"tool-call","id":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7fa07d0f-e70a-460d-b685-bf8a63b6a8a0"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[58,59,60,61,62],"surfaceOp":"append"} -{"type":"tool/call","seq":64,"time":1786547097893,"data":{"turn":1,"step":6,"callId":"bash-exit","name":"bash","arguments":"{\"command\":\"exit 9\"}"}} -{"type":"tool/result","seq":65,"time":1786547098003,"data":{"turn":1,"step":6,"message":{"source":{"kind":"tool","callId":"bash-exit"},"content":[{"type":"tool-result","toolCallId":"bash-exit","content":[{"type":"text","text":"exit\n[shell exited: code 9]\nThe persistent bash shell was reset; the next bash call starts from the workspace with a fresh current directory and environment."}],"isError":false}],"role":"user","id":"ccb91a28-4034-49bf-967d-450f68f7f9b8"}},"sourceEventSeqs":[64],"surfaceOp":"append"} -{"type":"step/end","seq":66,"time":1786547098003,"data":{"turn":1,"step":6}} -{"type":"step/start","seq":67,"time":1786547098003,"data":{"turn":1,"step":7}} -{"type":"assistant/chunk","seq":68,"time":1786547098003,"data":{"turn":1,"step":7,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":69,"time":1786547098003,"data":{"turn":1,"step":7,"chunk":{"type":"text-delta","index":0,"text":"PERSISTENT_TOOLS_OK"}}} -{"type":"assistant/chunk","seq":70,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PERSISTENT_TOOLS_OK"}}}} -{"type":"assistant/chunk","seq":71,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":72,"time":1785331618807,"data":{"turn":1,"step":7,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":73,"time":1786547098003,"data":{"turn":1,"step":7,"message":{"role":"assistant","content":[{"type":"text","text":"PERSISTENT_TOOLS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"43efc58a-46a1-4813-995f-1dc489438942"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[68,69,70,71,72],"surfaceOp":"append"} -{"type":"step/end","seq":74,"time":1786547098003,"data":{"turn":1,"step":7}} -{"type":"turn/end","seq":75,"time":1786547098004,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl deleted file mode 100644 index fc0a24eb66..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/notifications.expected.jsonl +++ /dev/null @@ -1,186 +0,0 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"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"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"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":"user/message","seq":4,"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"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":17,"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":18,"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":19,"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":20,"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":21,"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":22,"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":23,"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":24,"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":25,"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":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":"echo"}}}}} -{"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":" probe"}}}}} -{"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":" and"}}}}} -{"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":" prompt"}}}}} -{"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":" '"}}}}} -{"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":"Reply"}}}}} -{"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":" with"}}}}} -{"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":" exactly"}}}}} -{"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":" child"}}}}} -{"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":" answer"}}}}} -{"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":" "}}}}} -{"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":"42"}}}}} -{"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":".'\n"}}}}} -{"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":"2"}}}}} -{"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":"."}}}}} -{"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":" Then"}}}}} -{"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":" reply"}}}}} -{"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":" with"}}}}} -{"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":" the"}}}}} -{"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":" sub"}}}}} -{"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":"agent"}}}}} -{"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":"'s"}}}}} -{"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":" final"}}}}} -{"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":" answer"}}}}} -{"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":" verb"}}}}} -{"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":"atim"}}}}} -{"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":".\n\n"}}}}} -{"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":"Let"}}}}} -{"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":" me"}}}}} -{"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":" do"}}}}} -{"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":" this"}}}}} -{"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":" step"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":61,"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":62,"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":63,"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":64,"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":65,"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":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":"description"}}}}} -{"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":"\""}}}}} -{"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":": "}}}}} -{"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":"echo"}}}}} -{"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":" probe"}}}}} -{"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":"\""}}}}} -{"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":", "}}}}} -{"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":"prom"}}}}} -{"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":"pt"}}}}} -{"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":"\""}}}}} -{"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":": "}}}}} -{"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":"\""}}}}} -{"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":"Reply"}}}}} -{"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":" with"}}}}} -{"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":" exactly"}}}}} -{"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":" child"}}}}} -{"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":" answer"}}}}} -{"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":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":90,"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":91,"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":92,"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":93,"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":94,"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":95,"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":96,"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":97,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","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.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/call","seq":98,"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":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":17,"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":18,"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":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":"child"}}}}} -{"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":" answer"}}}}} -{"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":"reasoning-delta","index":0,"text":"42"}}}}} -{"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":"child"}}}}} -{"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":" answer"}}}}} -{"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":" "}}}}} -{"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":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"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":31,"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":32,"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":33,"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":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":35,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[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],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":37,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} -{"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":99,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404"},"content":[{"type":"tool-result","toolCallId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[98],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":100,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":101,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":102,"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":103,"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":104,"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":105,"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":106,"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":107,"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":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":"child"}}}}} -{"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":" answer"}}}}} -{"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":" "}}}}} -{"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":"42"}}}}} -{"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":".\""}}}}} -{"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":" Now"}}}}} -{"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":" I"}}}}} -{"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":" need"}}}}} -{"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":" to"}}}}} -{"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":" reply"}}}}} -{"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":" with"}}}}} -{"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":" the"}}}}} -{"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":" sub"}}}}} -{"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":"agent"}}}}} -{"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":"'s"}}}}} -{"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":" final"}}}}} -{"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":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":126,"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":127,"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":128,"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":129,"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":130,"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":131,"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":132,"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":133,"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":134,"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":135,"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":136,"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":137,"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":138,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":139,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","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."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":140,"time":0,"data":{"turn":1,"step":2}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":141,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl deleted file mode 100644 index 0e7855cd86..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/session.1.jsonl +++ /dev/null @@ -1,22 +0,0 @@ -{"type":"session","version":0,"id":"0b7fd85c-9f6f-4d46-b954-363984ce66fb","createdAt":1785097410282,"cwd":"{{cwd}}","parentSession":"sdk-snapshot-subagent","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498591161,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"}]}} -{"type":"turn/start","seq":1,"time":1785821460991,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821460991,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821461003,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} -{"type":"step/start","seq":4,"time":1785730507335,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730507335,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786358111405,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"17bd0771-d228-4805-a797-7be9c0b59d20"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786358111405,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} -{"type":"assistant/chunk","seq":25,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":26,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":31,"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":32,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":34,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":35,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[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],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":1785730507344,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":37,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/session.jsonl deleted file mode 100644 index dc9c8febc2..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn-in-process/session.jsonl +++ /dev/null @@ -1,33 +0,0 @@ -{"type":"session","version":0,"id":"sdk-snapshot-subagent","createdAt":1785097408901,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498591109,"data":{"target":"next-turn","start":0,"inserted":[{"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"},"role":"user","id":"ce62572c-2af9-4162-aca6-82ae0c89bc48"}]}} -{"type":"turn/start","seq":1,"time":1785821460945,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821460945,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1785097408908,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498591135,"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"},"role":"user","id":"ce62572c-2af9-4162-aca6-82ae0c89bc48"},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1785498591135,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498591137,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785730507304,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1785097409666,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1785097409691,"data":{"turn":1,"step":1,"index":0,"dt":[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,79,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":64,"time":1785097410056,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} -{"type":"tool-call-chunks","seq0":65,"time0":1785097410057,"data":{"turn":1,"step":1,"index":1,"dt":[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,57,1],"id":"call_00_oHPNQ1nLoakoaAGXIxCM7404","name":"subagent","args":["","{","\"","description","\"",": ","\"","echo"," probe","\"",", ","\"","prom","pt","\"",": ","\"","Reply"," with"," exactly",":"," child"," answer"," ","42",".","\"","}"]}} -{"type":"assistant/chunk","seq":93,"time":1785097410272,"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":94,"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":95,"time":1785498591150,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}}}} -{"type":"assistant/chunk","seq":96,"time":1785730507314,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":97,"time":1785730507314,"data":{"turn":1,"step":1,"message":{"role":"assistant","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.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"07d04a49-4aef-4ccc-a95d-20b38c37ea06"},"usage":{"inputTokens":135,"outputTokens":124,"cacheReadTokens":1664,"reasoningTokens":55}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96],"surfaceOp":"append"} -{"type":"tool/call","seq":98,"time":1785730507315,"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":99,"time":1785730507345,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404"},"content":[{"type":"tool-result","toolCallId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false}],"role":"user","id":"5757a7d9-68ed-4190-a29b-586ab0afdd5f"}},"sourceEventSeqs":[98],"surfaceOp":"append"} -{"type":"step/end","seq":100,"time":1785730507345,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":101,"time":1785730507355,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":102,"time":1785097411813,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":103,"time0":1785097411839,"data":{"turn":1,"step":2,"index":0,"dt":[0,26,1,26,0,0,0,0,26,0,1,0,0,25,0,0,28,0,0,1,0,0,23,1,0],"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":129,"time":1785097411997,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":130,"time0":1785097411997,"data":{"turn":1,"step":2,"index":1,"dt":[0,26,1,1],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":135,"time":1785097412025,"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":136,"time":1785097412025,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":137,"time":1785498591207,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}}}} -{"type":"assistant/chunk","seq":138,"time":1785730507362,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":139,"time":1785730507362,"data":{"turn":1,"step":2,"message":{"role":"assistant","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."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"7e4e2067-1d5f-4009-a397-acd58c3b3ba3"},"usage":{"inputTokens":19,"outputTokens":32,"cacheReadTokens":1920,"reasoningTokens":26}},"sourceEventSeqs":[102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,120,121,122,123,124,125,126,127,128,129,130,131,132,133,134,135,136,137,138],"surfaceOp":"append"} -{"type":"step/end","seq":140,"time":1785730507362,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":141,"time":1785730507362,"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 deleted file mode 100644 index eb60d67a0c..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl +++ /dev/null @@ -1,42 +0,0 @@ -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"running"}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}}} -{"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":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":5,"time":0,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[4],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":8,"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":9,"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":10,"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":11,"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":12,"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":13,"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":14,"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":15,"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":16,"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":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":"SD"}}}}} -{"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":"K"}}}}} -{"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":" snapshot"}}}}} -{"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":" OK"}}}}} -{"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":"reasoning-delta","index":0,"text":" Let"}}}}} -{"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":" me"}}}}} -{"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":" do"}}}}} -{"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":" that"}}}}} -{"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":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"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":29,"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":30,"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":31,"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":32,"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":33,"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":34,"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":35,"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":36,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":37,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","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"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":38,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":39,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} -{"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} diff --git a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl b/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl deleted file mode 100644 index 08b526a0ef..0000000000 --- a/examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl +++ /dev/null @@ -1,20 +0,0 @@ -{"type":"session","version":0,"id":"sdk-snapshot-text","createdAt":1785097381464,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498588575,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"2950333f-90ff-4b11-b8f9-082612c97488"}]}} -{"type":"turn/start","seq":1,"time":1785821459144,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821459144,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":3,"time":1785097381472,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498588596,"data":{"content":[{"type":"text","text":"Reply with exactly: SDK snapshot OK"}],"source":{"kind":"user"},"role":"user","id":"2950333f-90ff-4b11-b8f9-082612c97488"},"surfaceOp":"append"} -{"type":"session/title","seq":5,"time":1785498588596,"data":{"title":"Reply with exactly: SDK snapshot","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498588599,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":7,"time":1785730505700,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":8,"time":1785097382117,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":9,"time0":1785097382145,"data":{"turn":1,"step":1,"index":0,"dt":[27,1,0,0,24,1,0,0,0,26,0,1,25,1,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","SD","K"," snapshot"," OK","\"."," Let"," me"," do"," that","."]}} -{"type":"assistant/chunk","seq":28,"time":1785097382278,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":29,"time0":1785097382278,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,0],"texts":["SD","K"," snapshot"," OK"]}} -{"type":"assistant/chunk","seq":33,"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":34,"time":1785097382279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SDK snapshot OK"}}}} -{"type":"assistant/chunk","seq":35,"time":1785498588608,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":36,"time":1785730505710,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":37,"time":1785730505710,"data":{"turn":1,"step":1,"message":{"role":"assistant","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"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3dd28f2f-9314-41a8-bf15-851be3652c14"},"usage":{"inputTokens":1769,"outputTokens":24,"cacheReadTokens":0,"reasoningTokens":19}},"sourceEventSeqs":[8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} -{"type":"step/end","seq":38,"time":1785730505710,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":39,"time":1785730505710,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml deleted file mode 100644 index 882d942975..0000000000 --- a/examples/mcp-memory/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: 7e7de76f4123481b78898b8d62228e4821f3ebc9 -README.zh.md: f2bddf620f9f7e5ea5606eedb0186dd715e7e7df diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md deleted file mode 100644 index 7e7de76f41..0000000000 --- a/examples/mcp-memory/README.md +++ /dev/null @@ -1,101 +0,0 @@ -# Third-party memory MCP examples - -English | [中文](README.zh.md) - -These three **default-off reference configurations** connect one memory system to DSH through [`@deepseek-ai/dsh-mcp-client`](../../packages/mcp/mcp-client/README.md). Pick one, or copy the same generic MCP row for another server. - -These third-party configurations are provided as interoperability examples only. Their inclusion does not imply endorsement, recommendation, partnership, or ongoing support by DeepSeek. - -## What DSH does - -DSH parses the selected Cordis overlay, starts a configured stdio command or connects to a configured Streamable HTTP URL, discovers MCP tools, and exposes them as `mcp____`. DSH does **not** download the server, initialize its database, choose its model or embedding provider, create a cloud account, migrate vendor data, or supervise a separate HTTP service. For stdio, the generic client launches and stops the child with the DSH plugin lifecycle; for HTTP, the upstream service must already be running. - -The stdio bridge deliberately removes ambient variables whose names usually identify credentials and all `DSH_*` variables before launching a child; other ambient variables remain inherited. Each example adds only the baseline override it needs. If an optional upstream feature needs another secret, add that variable to the row's `config.env` instead of putting the secret directly in YAML. - -## Choose one - -| System | Tested pin | Transport | Upstream prerequisite | -|---|---:|---|---| -| [Memorix](https://github.com/AVIDS2/memorix) | `memorix@1.3.0` (`500792cad3144142293bfbb20acb4841c9f7fcfa`) | stdio | Node 22.18+ and `npm install --global memorix@1.3.0` | -| [MCP Reference Memory](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | `@modelcontextprotocol/server-memory@2026.7.4` (`6dd0a683e198783e30feabf7abaf42f925bd18b1`) | stdio | `npm install --global @modelcontextprotocol/server-memory@2026.7.4` | -| [Engram](https://github.com/Gentleman-Programming/engram) | `v1.20.0` (`ba9e46ced152c37a7cb9e576153c41995873e2fc`) | stdio | Go 1.25.10+ and `go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0`, or the matching release binary | - -## Enable one - -Pass one overlay to DSH: - -```sh -dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" -``` - -Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--patch` keeps all three disabled. - -To keep the selection across runs, merge the chosen file's single `insert` patch into a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` for every profile on the machine. Do not copy over an existing file: it may already contain unrelated user patches. - -## Provider setup - -### Memorix - -```sh -npm install --global memorix@1.3.0 -dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" -``` - -Memorix works in local heuristic mode without an LLM or embedding service. Configure optional providers in Memorix's own `~/.memorix/config.toml` or project `memorix.toml`. The example keeps Memorix's Git-project identity from the DSH working directory and uses Memorix's own `~/.memorix/data` default. Set `MEMORIX_DATA_DIR` before starting DSH to override it. - -### MCP Reference Memory - -```sh -npm install --global @modelcontextprotocol/server-memory@2026.7.4 -dsh web --patch "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" -``` - -This reference server stores a local knowledge graph and exposes entity, relation, observation, read, search, and open tools. It needs no model or embedding service. The example stores its JSONL at `$HOME/.dsh-mcp-reference-memory.jsonl` instead of the installed npm package directory. Set `MEMORY_FILE_PATH` before starting DSH to override it. - -Search is case-insensitive substring matching over entity names, types, and observations, not semantic retrieval. The server does not add embeddings, automatic summarization, conflict resolution, or a forgetting policy. - -### Engram - -```sh -go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 -dsh web --patch "$PWD/examples/mcp-memory/engram.cordis.yml" -``` - -Engram owns storage and project selection: it uses `~/.engram` by default, detects the Git project from the DSH working directory, and accepts `ENGRAM_DATA_DIR` or `ENGRAM_PROJECT` as ambient overrides. - -## Optional shared model instruction - -Add this short, vendor-neutral instruction to your existing model instructions if the server's tool descriptions do not trigger memory use reliably: - -> When the user asks you to remember something, call a memory write tool. When historical information may be relevant, search memory and use relevant results. - -This is additive guidance only. The examples do not replace DSH's system-prompt persona. - -## Verify write, fresh-session recall, and use - -Use one unique value and keep the provider's storage scope unchanged throughout: - -1. In DSH session A, ask: `Remember that my validation drink is lapsang-.` Confirm the model called the provider's write tool and the tool returned success. -2. Create DSH session B in the same running Host. Do not copy session A's conversation. Ask: `What is my validation drink? Check memory.` Confirm the model called the provider's search or recall tool and returned the value. -3. Still in session B, ask: `Use that preference to suggest one drink for the meeting.` Confirm the answer uses the recalled value. - -A new DSH session is required; a Host restart is not. Restart or HMR is needed only after an MCP child crashes because the current generic client does not auto-reconnect; its tool registrations remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport. Initial discovery is asynchronous, so wait for the provider's `mcp__...` tools before sending the first validation prompt. - -## Bring another MCP server - -Copy the same entry fields and use a unique `id` and `serverName`: - -```yaml -- insert: - - id: memory-my-server - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: my-memory - transport: stdio - command: my-memory-mcp - args: [] - env: {} - cwd: !!js process.cwd() -``` - -For a remote server, use `transport: streamable-http`, `url`, and `headers` instead. Provider-specific installation, identity, authentication, models, embeddings, persistence, and licensing remain the provider's responsibility. diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md deleted file mode 100644 index f2bddf620f..0000000000 --- a/examples/mcp-memory/README.zh.md +++ /dev/null @@ -1,101 +0,0 @@ -# 第三方记忆 MCP 示例 - -[English](README.md) | 中文 - -这三份**默认关闭的参考配置**通过 [`@deepseek-ai/dsh-mcp-client`](../../packages/mcp/mcp-client/README.md) 将一个记忆系统连接到 DSH。请选择其中一份,或复制相同的通用 MCP 配置项来连接其他服务器。 - -这些第三方配置仅作为互操作参考;收录不代表 DeepSeek 的认可、推荐、合作关系或持续支持承诺。 - -## DSH 负责什么 - -DSH 解析选中的 Cordis overlay,启动已配置的 stdio 命令或连接已配置的 Streamable HTTP URL,发现 MCP 工具,并以 `mcp____` 的形式公开这些工具。DSH **不负责** 下载服务器、初始化其数据库、选择模型或 embedding 提供方、创建云端账户、迁移提供方数据,也不监管独立的 HTTP 服务。对于 stdio,通用客户端会随 DSH 插件生命周期启动和停止子进程;对于 HTTP,上游服务必须已经运行。 - -stdio 桥接器在启动子进程前会主动移除环境中名称通常表示凭据的变量和所有 `DSH_*` 变量;其余环境变量仍会继承。每份示例仅添加其基线所需的覆盖项。如果某个可选的上游功能还需要其他密钥,请将该变量添加到配置项的 `config.env`,不要把密钥直接写进 YAML。 - -## 选择一个 - -| 系统 | 已测试版本 | 传输方式 | 上游前置条件 | -|---|---:|---|---| -| [Memorix](https://github.com/AVIDS2/memorix) | `memorix@1.3.0`(`500792cad3144142293bfbb20acb4841c9f7fcfa`) | stdio | Node 22.18+,并执行 `npm install --global memorix@1.3.0` | -| [MCP Reference Memory](https://github.com/modelcontextprotocol/servers/tree/main/src/memory) | `@modelcontextprotocol/server-memory@2026.7.4`(`6dd0a683e198783e30feabf7abaf42f925bd18b1`) | stdio | `npm install --global @modelcontextprotocol/server-memory@2026.7.4` | -| [Engram](https://github.com/Gentleman-Programming/engram) | `v1.20.0`(`ba9e46ced152c37a7cb9e576153c41995873e2fc`) | stdio | Go 1.25.10+,并执行 `go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0`,或安装匹配的发布版二进制文件 | - -## 启用一个 - -将一份 overlay 传给 DSH: - -```sh -dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" -``` - -请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--patch` 就会让这三项全部保持关闭。 - -如果要跨次运行保留所选配置,请将对应文件中的单个 `insert` patch 合并到用户 patch 层:只对一个 profile 生效则写入 `$DSH_HOME/profiles//cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。 - -## 提供方设置 - -### Memorix - -```sh -npm install --global memorix@1.3.0 -dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" -``` - -Memorix 无需 LLM(大语言模型)或 embedding 服务,即可在本地启发式模式下运行。请在 Memorix 自己的 `~/.memorix/config.toml` 或项目 `memorix.toml` 中配置可选提供方。该示例沿用 DSH 工作目录中的 Git 项目标识,并使用 Memorix 自身的默认目录 `~/.memorix/data`。若要覆盖该目录,请在启动 DSH 前设置 `MEMORIX_DATA_DIR`。 - -### MCP Reference Memory - -```sh -npm install --global @modelcontextprotocol/server-memory@2026.7.4 -dsh web --patch "$PWD/examples/mcp-memory/mcp-reference-memory.cordis.yml" -``` - -该参考服务器存储本地知识图谱,并公开实体、关系、观察、读取、搜索和打开工具。它不需要模型或 embedding 服务。该示例将 JSONL 存储在 `$HOME/.dsh-mcp-reference-memory.jsonl`,而不是已安装的 npm 包目录中。若要覆盖该路径,请在启动 DSH 前设置 `MEMORY_FILE_PATH`。 - -搜索只对实体名称、类型和观察进行不区分大小写的子字符串匹配,不是语义检索。该服务器不提供 embedding、自动摘要、冲突消解或遗忘策略。 - -### Engram - -```sh -go install github.com/Gentleman-Programming/engram/cmd/engram@v1.20.0 -dsh web --patch "$PWD/examples/mcp-memory/engram.cordis.yml" -``` - -Engram 负责存储和项目选择:它默认使用 `~/.engram`,从 DSH 工作目录检测 Git 项目,并接受 `ENGRAM_DATA_DIR` 或 `ENGRAM_PROJECT` 作为环境覆盖项。 - -## 可选的共用模型指令 - -如果服务器的工具描述无法可靠触发记忆使用,请将以下简短、与提供方无关的指令添加到你现有的模型指令中: - -> 用户要求记住某事时调用记忆写入工具;历史信息可能相关时,检索记忆并使用相关结果。 - -这只是附加指导。示例不会替换 DSH 系统提示词中的 persona。 - -## 验证写入、新会话召回和使用 - -请在整个过程中使用一个唯一值,并保持提供方的存储范围不变: - -1. 在 DSH 会话 A 中提出:`Remember that my validation drink is lapsang-.`。确认模型调用了提供方的写入工具,并且工具返回成功。 -2. 在同一个仍在运行的 Host 中创建 DSH 会话 B。不要复制会话 A 的对话。提出:`What is my validation drink? Check memory.`。确认模型调用了提供方的搜索或召回工具,并返回该值。 -3. 继续在会话 B 中提出:`Use that preference to suggest one drink for the meeting.`。确认回答使用了召回的值。 - -必须新建 DSH 会话,但不需要重启 Host。只有 MCP 子进程崩溃后才需要重启或执行 HMR(热模块替换),因为当前的通用客户端不会自动重连;其工具注册会一直保留,直到插件 dispose(资源释放)或成功重新同步,针对已关闭传输的调用可能失败。初始发现过程是异步的,因此发送第一条验证提示词前,请等待提供方的 `mcp__...` 工具出现。 - -## 接入其他 MCP 服务器 - -复制相同的条目字段,并使用唯一的 `id` 和 `serverName`: - -```yaml -- insert: - - id: memory-my-server - name: '@deepseek-ai/dsh-mcp-client' - config: - serverName: my-memory - transport: stdio - command: my-memory-mcp - args: [] - env: {} - cwd: !!js process.cwd() -``` - -对于远程服务器,请改用 `transport: streamable-http`、`url` 和 `headers`。提供方专属的安装、身份、认证、模型、embedding、持久化和许可仍由提供方负责。 diff --git a/examples/package.json b/examples/package.json deleted file mode 100644 index 021ed0dc32..0000000000 --- a/examples/package.json +++ /dev/null @@ -1,119 +0,0 @@ -{ - "name": "dsh-examples", - "private": true, - "version": "0.0.1", - "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", - "dependencies": { - "@deepseek-ai/cordis-plugin-hmr": "workspace:*", - "@deepseek-ai/cordis-plugin-include": "workspace:*", - "@deepseek-ai/cordis-plugin-logger-console": "workspace:*", - "@deepseek-ai/cordis-plugin-timer": "workspace:*", - "@deepseek-ai/dsh-acp-demo": "workspace:*", - "@deepseek-ai/dsh-agent": "workspace:*", - "@deepseek-ai/dsh-agent-loop": "workspace:*", - "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", - "@deepseek-ai/dsh-app-boot": "workspace:*", - "@deepseek-ai/dsh-attachment-local": "workspace:*", - "@deepseek-ai/dsh-shell": "workspace:*", - "@deepseek-ai/dsh-shell-env": "workspace:*", - "@deepseek-ai/dsh-bash-local": "workspace:*", - "@deepseek-ai/dsh-bash-sandbox": "workspace:*", - "@deepseek-ai/dsh-code-runtime-worker-thread": "workspace:*", - "@deepseek-ai/dsh-command-feedback": "workspace:*", - "@deepseek-ai/dsh-command-goal": "workspace:*", - "@deepseek-ai/dsh-commands": "workspace:*", - "@deepseek-ai/dsh-compaction": "workspace:*", - "@deepseek-ai/dsh-compaction-basic": "workspace:*", - "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:*", - "@deepseek-ai/dsh-credentials-local": "workspace:*", - "@deepseek-ai/dsh-e2b": "workspace:*", - "@deepseek-ai/dsh-fs-e2b": "workspace:*", - "@deepseek-ai/dsh-fs-local": "workspace:*", - "@deepseek-ai/dsh-fs-observation-policy": "workspace:*", - "@deepseek-ai/dsh-fs-sandbox": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:*", - "@deepseek-ai/dsh-goal-round-driver": "workspace:*", - "@deepseek-ai/dsh-hooks-claude-code": "workspace:*", - "@deepseek-ai/dsh-hooks-codex": "workspace:*", - "@deepseek-ai/dsh-cordis-host-runner": "workspace:*", - "@deepseek-ai/dsh-invariants": "workspace:*", - "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:*", - "@deepseek-ai/dsh-llm": "workspace:*", - "@deepseek-ai/dsh-llm-deepseek": "workspace:*", - "@deepseek-ai/dsh-llm-pi-ai": "workspace:*", - "@deepseek-ai/dsh-llm-replay": "workspace:*", - "@deepseek-ai/dsh-loader-smoke": "workspace:*", - "@deepseek-ai/dsh-lsp": "workspace:*", - "@deepseek-ai/dsh-lsp-stdio": "workspace:*", - "@deepseek-ai/dsh-permission-presets": "workspace:*", - "@deepseek-ai/dsh-plan-mode": "workspace:*", - "@deepseek-ai/dsh-terminal": "workspace:*", - "@deepseek-ai/dsh-terminal-bash": "workspace:*", - "@deepseek-ai/dsh-pwsh-local": "workspace:*", - "@deepseek-ai/dsh-repeat-tool-reminder": "workspace:*", - "@deepseek-ai/dsh-sandbox": "workspace:*", - "@deepseek-ai/dsh-sandbox-local": "workspace:*", - "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/dsh-scope": "workspace:*", - "@deepseek-ai/dsh-session": "workspace:*", - "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:*", - "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:*", - "@deepseek-ai/dsh-session-projection": "workspace:*", - "@deepseek-ai/dsh-session-query": "workspace:*", - "@deepseek-ai/dsh-session-query-sqlite": "workspace:*", - "@deepseek-ai/dsh-session-reference": "workspace:*", - "@deepseek-ai/dsh-session-telemetry-otel": "workspace:*", - "@deepseek-ai/dsh-session-title": "workspace:*", - "@deepseek-ai/dsh-session-title-first-prompt-llm": "workspace:*", - "@deepseek-ai/dsh-settings-file": "workspace:*", - "@deepseek-ai/dsh-skill": "workspace:*", - "@deepseek-ai/dsh-skill-filesystem": "workspace:*", - "@deepseek-ai/dsh-spill-local": "workspace:*", - "@deepseek-ai/dsh-spill-policy": "workspace:*", - "@deepseek-ai/dsh-subagent": "workspace:*", - "@deepseek-ai/dsh-subagent-acp": "workspace:*", - "@deepseek-ai/dsh-subagent-claude-code": "workspace:*", - "@deepseek-ai/dsh-subagent-codex": "workspace:*", - "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", - "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:*", - "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:*", - "@deepseek-ai/dsh-subprocess-e2b": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", - "@deepseek-ai/dsh-system-prompt": "workspace:*", - "@deepseek-ai/dsh-jobs-local": "workspace:*", - "@deepseek-ai/dsh-team": "workspace:*", - "@deepseek-ai/dsh-time-context": "workspace:*", - "@deepseek-ai/dsh-tool-call-timeout-policy": "workspace:*", - "@deepseek-ai/dsh-token-meter": "workspace:*", - "@deepseek-ai/dsh-tool-ask-user": "workspace:*", - "@deepseek-ai/dsh-tool-bash": "workspace:*", - "@deepseek-ai/dsh-tool-bash-persistent": "workspace:*", - "@deepseek-ai/dsh-tool-cordis": "workspace:*", - "@deepseek-ai/dsh-tool-fs": "workspace:*", - "@deepseek-ai/dsh-tool-fs-search": "workspace:*", - "@deepseek-ai/dsh-tool-goal": "workspace:*", - "@deepseek-ai/dsh-tool-lsp": "workspace:*", - "@deepseek-ai/dsh-tool-terminal": "workspace:*", - "@deepseek-ai/dsh-tool-pwsh": "workspace:*", - "@deepseek-ai/dsh-tool-ralph": "workspace:*", - "@deepseek-ai/dsh-tool-session-query": "workspace:*", - "@deepseek-ai/dsh-tool-skill": "workspace:*", - "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:*", - "@deepseek-ai/dsh-tool-subagent": "workspace:*", - "@deepseek-ai/dsh-tool-subagent-control": "workspace:*", - "@deepseek-ai/dsh-tool-subagent-report": "workspace:*", - "@deepseek-ai/dsh-tool-jobs": "workspace:*", - "@deepseek-ai/dsh-tool-team": "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-user-approval": "workspace:*", - "@deepseek-ai/dsh-user-questions": "workspace:*", - "@deepseek-ai/dsh-web": "workspace:*", - "@deepseek-ai/dsh-web-fetch-http": "workspace:*", - "@deepseek-ai/dsh-workflow-worker-thread": "workspace:*", - "@deepseek-ai/dsh-agent-instructions": "workspace:*" - } -} diff --git a/examples/web-cordis/.gitignore b/examples/web-cordis/.gitignore deleted file mode 100644 index 4da346bc81..0000000000 --- a/examples/web-cordis/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -.sessions/ -.storages/ diff --git a/examples/web-cordis/README.i18n.yaml b/examples/web-cordis/README.i18n.yaml deleted file mode 100644 index b5eaad2e9f..0000000000 --- a/examples/web-cordis/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write examples/web-cordis/README.md -README.md: 1c6bc3f2202a212225c60c9c2e849a039b9a5998 -README.zh.md: b9ab7afcce741c96f161270f6420105c95f2690c diff --git a/examples/web-cordis/README.md b/examples/web-cordis/README.md deleted file mode 100644 index 1c6bc3f220..0000000000 --- a/examples/web-cordis/README.md +++ /dev/null @@ -1,21 +0,0 @@ -# web-cordis - -English | [中文](README.zh.md) - -Self-referential demonstration of [`@deepseek-ai/dsh-tool-cordis`](../../packages/extensions/tool-cordis/README.md). The agent can inspect its current Cordis process and mount or unmount model-authored plugins in memory. Temporary plugins disappear when they are unmounted or the process exits and may affect other sessions in the same process. - -## Run it - -Start the browser interface: - -```sh -pnpm run demo:cordis -``` - -Start the ACP automation server instead: - -```sh -pnpm run demo:cordis acp -``` - -Both commands require `DEEPSEEK_API_KEY`. The [Cordis tool reference](../../packages/extensions/tool-cordis/README.md) defines the tool arguments, lifetime, cleanup, and safety contracts. diff --git a/examples/web-cordis/README.zh.md b/examples/web-cordis/README.zh.md deleted file mode 100644 index b9ab7afcce..0000000000 --- a/examples/web-cordis/README.zh.md +++ /dev/null @@ -1,21 +0,0 @@ -# web-cordis - -[English](README.md) | 中文 - -[`@deepseek-ai/dsh-tool-cordis`](../../packages/extensions/tool-cordis/README.md) 的自指示例。agent(智能体)可以检查当前 Cordis 进程,并在内存中挂载或卸载模型编写的插件。临时插件会在卸载或进程退出时消失,并可能影响同一进程中的其他会话。 - -## 运行 - -启动浏览器界面: - -```sh -pnpm run demo:cordis -``` - -改为启动 ACP(Agent Client Protocol)自动化服务器: - -```sh -pnpm run demo:cordis acp -``` - -这两条命令都需要 `DEEPSEEK_API_KEY`。[Cordis 工具参考](../../packages/extensions/tool-cordis/README.md)定义了四类约定:工具参数、存续时间、清理行为和安全性。 diff --git a/examples/web-schedule/README.i18n.yaml b/examples/web-schedule/README.i18n.yaml deleted file mode 100644 index 07d42bdc94..0000000000 --- a/examples/web-schedule/README.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write examples/web-schedule/README.md -README.md: 6df88b1ce58080b05bc1ea4de98507263180dfac -README.zh.md: 83e6c7da5e46527a35344b4980e9378a355cb1fc diff --git a/examples/web-schedule/README.md b/examples/web-schedule/README.md deleted file mode 100644 index 6df88b1ce5..0000000000 --- a/examples/web-schedule/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Session-local Schedule - -English | [中文](README.zh.md) - -This overlay opts one `dsh web` process into Schedule reminders without changing the shipped default Web composition: - -```sh -dsh web --patch examples/web-schedule/cordis.yml -``` - -The current overlay supports reminders created with a positive whole-number `after_seconds`, an absolute `at` target, or a fixed-rate `every_seconds` interval of at least 300 seconds. The model manages them through `schedule_create`, `schedule_list`, and `schedule_delete`; every result identifies delivery as `session-local`. - -The browser attaches its IANA zone to each prompt. Time-context tells the model to interpret otherwise-unqualified dates and times in that request's browser zone. This assumption belongs to natural-language interpretation only: `schedule_create.at` must be either a strict RFC 3339 date-time with `Z` or a numeric offset, or `{ date, time, time_zone }` with an explicit `UTC` or IANA Area/Location zone. Schedule does not retain or infer a Session default zone. Daylight-saving gaps are rejected, overlaps choose the first instant, and successful records keep only the resulting UTC target. - -The original Session log owns each reminder. A live root Agent waits until it is fully idle, then queues a normal follow-up turn in that conversation. It never steers current work and adds no separate receipt or reminder card. Closing the process or leaving the Session cold stops its in-memory timer without deleting the record; reopening that same Session restores the wait and delivers an overdue reminder. Reading cold history never activates it, and a fork does not inherit its parent's reminders. - -Every reminders stay aligned to their creation time. If one is overdue, only its latest due occurrence is presented and the next target remains on the original fixed-rate sequence. All distinct Every records overdue at the same idle decision are combined into one follow-up with one occurrence each; missed intervals do not create a backlog. Due one-shots run before that batch. Calendar and Cron expressions are not supported. - -Create and actual delete operations acknowledge success only after Session persistence confirms their event prefix. Schedule does not provide browser, operating-system, email, SMS, or other external notification. A durable dispatch records that the follow-up was queued; it does not acknowledge model success or user receipt. diff --git a/examples/web-schedule/README.zh.md b/examples/web-schedule/README.zh.md deleted file mode 100644 index 83e6c7da5e..0000000000 --- a/examples/web-schedule/README.zh.md +++ /dev/null @@ -1,19 +0,0 @@ -# 仅限 Session 内的 Schedule - -[English](README.md) | 中文 - -此 overlay 让一个 `dsh web` 进程显式启用 Schedule 提醒,同时不改变交付的默认 Web 组合: - -```sh -dsh web --patch examples/web-schedule/cordis.yml -``` - -当前 overlay 支持使用正整数 `after_seconds`、绝对时间 `at` 目标,或至少 300 秒的固定速率 `every_seconds` 间隔创建提醒。模型通过 `schedule_create`、`schedule_list` 和 `schedule_delete` 管理它们;每个结果都会把交付标为 `session-local`。 - -浏览器会为每条提示词附加其 IANA 时区。Time-context 会告诉模型,把未明确限定时区的日期和时间解释为该请求的浏览器时区。此假设仅用于自然语言解释:`schedule_create.at` 必须是带 `Z` 或数值偏移量且严格符合 RFC 3339 的日期时间,或是带显式 `UTC` 或 IANA Area/Location 时区的 `{ date, time, time_zone }`。Schedule 不保留或推断 Session 默认时区。夏令时缺口会被拒绝,重叠时段选择第一个时刻;成功创建的记录只保留所得的 UTC 目标。 - -每条提醒由原 Session 日志拥有。live 根 Agent 会等待到完全 idle,再在该对话中排入一个普通 follow-up 轮次。它绝不会中途引导当前工作,也不会添加独立回执或提醒卡片。关闭进程或让 Session 保持 cold 会停止内存 timer,但不会删除记录;重新打开同一个 Session 会恢复等待并交付逾期提醒。查看 cold 历史不会激活提醒,fork 也不会继承父 Session 的提醒。 - -Every 提醒始终与其创建时刻对齐。如果提醒逾期,只会呈现最新一个到期发生时点,下一个目标仍保留在原固定速率序列上。同一次 idle 决策中逾期的所有不同 Every 记录会合并为一个 follow-up,每条记录各有一个发生时点;错过的间隔不会形成积压。已到期的一次性提醒会在该批次之前运行。不支持日历表达式和 Cron 表达式。 - -创建和实际删除操作只有在 Session persistence 确认对应事件前缀后才会确认成功。Schedule 不提供浏览器、操作系统、邮件、短信或其他外部通知。持久 dispatch 会记录 follow-up 已经入队;它不确认模型成功或用户已收到提醒。 diff --git a/knip.json b/knip.json index 7969b4f230..e5e331194b 100644 --- a/knip.json +++ b/knip.json @@ -25,72 +25,18 @@ ".": { "entry": [ "scripts/**/*.mjs", - "scripts/**/*.cjs" + "scripts/**/*.cjs", + "snapshots/**/*.mjs", + "scripts/types/client-build-environment/index.d.ts" + ], + "ignoreUnresolved": [ + "client-build-environment" ], "project": [ "scripts/**/*.ts", "scripts/**/*.mjs", - "scripts/**/*.cjs" - ] - }, - "examples": { - "entry": [ - "headless-agent/tests/fixtures/cli-mock-llm.ts", - "headless-agent/tests/fixtures/semantic-checkpoint-agent.ts", - "headless-agent/tests/fixtures/subagent-diagnostic-agent.ts", - "headless-agent/tests/fixtures/subagent-inheritance-agent.ts", - "headless-agent/tests/fixtures/subagent-settlement-fence.ts", - "headless-agent/tests/fixtures/workspace-context-resume-agent.ts", - "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/session-telemetry-otel-driver.ts", - "headless-agent/tests/fixtures/telemetry-redact-rule.ts", - "headless-agent/tests/fixtures/e2b/e2b/bin.ts", - "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", - "acp-agent/tests/fixtures/child-question-tripwire.ts", - "acp-agent/tests/fixtures/parent-sandbox-override.ts", - "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", - "acp-agent/tests/fixtures/subagent-durability-failure.ts", - "acp-agent/tests/fixtures/subagent-result-diagnostic.ts", - "acp-agent/tests/fixtures/subagent-report-fence.ts", - "acp-agent/tests/fixtures/subagent-settlement-marker.ts", - "acp-agent/tests/fixtures/workspace-context-compaction.ts", - "acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts", - "acp-agent/tests/fixtures/subagent/subagent-acp/driver.ts", - "acp-agent/tests/fixtures/subagent/subagent-claude-code/fixture.ts", - "acp-agent/tests/fixtures/subagent/subagent-claude-code/driver.ts", - "acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts", - "acp-agent/tests/fixtures/subagent/subagent-codex/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" - ], - "project": [ - "**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/.+" - ] - }, - "packages/util/home": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/host/webserver": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" + "scripts/**/*.cjs", + "snapshots/**/*.mjs" ] }, "packages/host/directory-picker-auto": { @@ -121,24 +67,6 @@ "tests/**/*.{ts,tsx}" ] }, - "packages/client/web-ui": { - "entry": [ - "tests/**/*.spec.{ts,tsx}" - ], - "project": [ - "src/**/*.{ts,tsx}", - "tests/**/*.{ts,tsx}" - ] - }, - "packages/client/runtime": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/api/remotes": { "entry": [ "tests/**/*.e2e.ts" @@ -146,9 +74,43 @@ "project": [ "src/**/*.ts", "tests/**/*.ts" + ] + }, + "packages/api/workspace-controller": { + "ignoreDependencies": [ + "zod" + ] + }, + "packages/client/ui-approval": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.tsx" + ] + }, + "packages/client/ui-conversation": { + "entry": [ + "tests/**/*.spec.{ts,tsx}", + "tests/**/*.perf.client.ts" + ], + "project": [ + "src/**/*.{ts,tsx}", + "tests/**/*.{ts,tsx}" ], "ignoreDependencies": [ - "@deepseek-ai/dsh-api-gateway" + "@deepseek-ai/dsh-client-ui-workspace" + ] + }, + "packages/client/ui-sidebar": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-workspace" + ] + }, + "packages/client/ui-subagent": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-client-ui-input-trigger" ] }, "packages/client/ui-primitives": { @@ -243,13 +205,19 @@ "tests/**/*.ts" ] }, - "packages/core/tools": { + "packages/experimental/webworker-runtime": { "entry": [ - "tests/**/*.spec.ts" + "tests/**/*.spec.ts", + "tests/compile/transform-corpus-check.ts", + "tests/fixtures/vfs-example/workspace/src/preview.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "buffer", + "@deepseek-ai/dsh-client-modules" ] }, "packages/typert/generator": { @@ -286,7 +254,8 @@ "packages/e2b/e2b": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/composition/bin.ts" ], "project": [ "src/**/*.ts", @@ -296,20 +265,19 @@ "packages/context/time-context": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/*.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" - ] - }, - "packages/context/tmux-context": { - "entry": [ - "tests/**/*.spec.ts" ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-bash-local", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-subprocess-local" ] }, "packages/lsp/lsp-stdio": { @@ -339,11 +307,20 @@ "packages/session/session-telemetry-otel": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/*.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@deepseek-ai/cordis-plugin-logger-console", + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-bash-local", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-subprocess-local" ] }, "packages/util/brand": { @@ -356,28 +333,10 @@ "src/**/*.ts" ] }, - "packages/util/timeout": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/util/output-retention": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/test-support/acp-snapshot": { + "packages/test-support/session-snapshot": { "entry": [ "tests/**/*.spec.ts", - "tests/fixtures/fake-acp-agent.ts" + "tests/fixtures/*.ts" ], "project": [ "src/**/*.ts", @@ -407,29 +366,19 @@ "packages/goal/goal": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/domain/seed-goal.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" - ] - }, - "packages/goal/goal-round-driver": { - "entry": [ - "tests/**/*.spec.ts" ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/goal/tool-goal": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-bash-local", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-subprocess-local" ] }, "packages/session-query/session-query-sqlite": { @@ -502,6 +451,16 @@ "tests/**/*.ts" ] }, + "packages/context/file-reference": { + "ignoreDependencies": [ + "zod" + ] + }, + "packages/context/session-reference": { + "ignoreDependencies": [ + "zod" + ] + }, "packages/session/session-checkpoint-policy": { "entry": [ "tests/**/*.spec.ts", @@ -512,15 +471,6 @@ "tests/**/*.ts" ] }, - "packages/util/home-paths": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/web/web-search-exa": { "entry": [ "tests/**/*.spec.ts", @@ -561,16 +511,6 @@ "tests/**/*.ts" ] }, - "packages/examples/acp-demo": { - "entry": [ - "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/examples/agent-spine-demo": { "entry": [ "tests/**/*.spec.ts", @@ -603,11 +543,6 @@ "zod" ] }, - "packages/examples/jsonrpc-demo": { - "project": [ - "src/**/*.ts" - ] - }, "packages/subagent/subagent-spawn-in-process": { "entry": [ "tests/**/*.spec.ts", @@ -622,31 +557,53 @@ "entry": [ "tests/**/*.spec.ts", "tests/**/*.e2e.ts", - "tests/mock-acp-server.ts" + "tests/mock-acp-server.ts", + "tests/fixtures/loader/*.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-tool-subagent" ] }, "packages/subagent/subagent-codex": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/loader/*.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-tool-subagent" ] }, "packages/subagent/subagent-claude-code": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/loader/*.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-subagent-codex", + "@deepseek-ai/dsh-tool-subagent" ] }, "packages/fs/tool-fs": { @@ -659,15 +616,6 @@ "tests/**/*.ts" ] }, - "packages/fs/tool-fs-search": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/mcp/mcp-client": { "entry": [ "tests/**/*.spec.ts", @@ -699,28 +647,21 @@ "tests/**/*.tsx" ] }, - "packages/client/ui-settings": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "apps/web": { "entry": [ "tests/**/*.e2e.ts", "tests/**/*.perf.ts", "tests/**/*.snapshot.ts", "tests/support.ts", - "src/node-module-stub.ts" + "src/node-module-stub.ts", + "src/preview.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" ], "ignoreDependencies": [ + "@deepseek-ai/dsh-client-store", "@deepseek-ai/dsh-client-ui-primitives", "@deepseek-ai/dsh-client-ui-slots", "@types/react", @@ -733,7 +674,8 @@ "entry": [ "tests/**/*.spec.ts", "tests/**/*.e2e.ts", - "tests/**/*.snapshot.ts" + "tests/profiles/**/fixtures/**/*.{ts,mjs}", + "tests/profiles/**/tests/fixtures/**/*.{ts,mjs}" ], "project": [ "src/**/*.ts", @@ -743,32 +685,50 @@ "@deepseek-ai/.+" ] }, - "packages/client/modules": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, - "packages/client/hmr": { - "entry": [ - "tests/**/*.spec.ts" - ], - "project": [ - "src/**/*.ts", - "tests/**/*.ts" - ] - }, "packages/subagent/subagent-dsh-sdk": { "entry": [ "tests/**/*.spec.ts", - "tests/**/*.e2e.ts" + "tests/**/*.e2e.ts", + "tests/fixtures/loader/*.ts" ], "project": [ "src/**/*.ts", "tests/**/*.ts" + ], + "ignoreDependencies": [ + "@deepseek-ai/dsh-agent-instructions", + "@deepseek-ai/dsh-agent-spine-demo", + "@deepseek-ai/dsh-llm-deepseek", + "@deepseek-ai/dsh-session-checkpoint-policy", + "@deepseek-ai/dsh-session-persistence-jsonl", + "@deepseek-ai/dsh-skill-filesystem", + "@deepseek-ai/dsh-tool-subagent" + ] + }, + "packages/shell/tool-pwsh": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/loader/driver.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/test-support/loader-smoke": { + "entry": [ + "tests/**/*.spec.ts", + "tests/fixtures/*.ts" + ], + "project": [ + "src/**/*.ts", + "tests/**/*.ts" + ] + }, + "packages/experimental/agent-team-profile": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-experimental-agent-team", + "@deepseek-ai/dsh-experimental-tool-agent-team" ] }, "packages/bundle/base": { @@ -781,6 +741,32 @@ "@deepseek-ai/dsh-code-runtime-worker-thread" ] }, + "packages/bundle/acp-app": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-acp" + ] + }, + "packages/bundle/sdk-app": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-sdk-jsonrpc-server" + ] + }, + "packages/bundle/sdk-minimal": { + "ignoreDependencies": [ + "@deepseek-ai/.+" + ] + }, + "packages/experimental/agent-team": { + "entry": [ + "tests/**/*.spec.ts", + "tests/**/*.e2e.ts" + ] + }, + "packages/experimental/agent-team-web-profile": { + "ignoreDependencies": [ + "@deepseek-ai/dsh-experimental-client-ui-agent-team" + ] + }, "packages/bundle/web-app": { "ignoreDependencies": [ "@deepseek-ai/.+" diff --git a/native/README.i18n.yaml b/native/README.i18n.yaml index 258111fbdb..0d7eebd170 100644 --- a/native/README.i18n.yaml +++ b/native/README.i18n.yaml @@ -3,4 +3,4 @@ # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write native/README.md README.md: 51c8da7b57df15e65b8e431ee18ce6cebb89d54b -README.zh.md: 55dd5eb0a197bd970b33f7943877fb3826858128 +README.zh.md: 6d07c6afb43e2763795a71639707454b9cfbe280 diff --git a/native/README.zh.md b/native/README.zh.md index 55dd5eb0a1..6d07c6afb4 100644 --- a/native/README.zh.md +++ b/native/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -与 DeepSeek Harness 一同维护的原生源码和公开包。[`landlock-run/` workspace](landlock-run/README.md) 负责 harness 使用的 Landlock 自限后执行启动器,包括其架构、由三个包组成的 npm 包家族、平台支持、开发工作流和[发布流程](landlock-run/docs/release.md)。 +与 DeepSeek Harness 一同维护的原生源码和公开包。[`landlock-run/` workspace](landlock-run/README.zh.md) 负责 harness 使用的 Landlock 自限后执行启动器,包括其架构、由三个包组成的 npm 包家族、平台支持、开发工作流和[发布流程](landlock-run/docs/release.md)。 ## Workspace 与发布边界 diff --git a/native/landlock-run/scripts/publish-release.mjs b/native/landlock-run/scripts/publish-release.mjs index 76c875b8d3..6953249d80 100644 --- a/native/landlock-run/scripts/publish-release.mjs +++ b/native/landlock-run/scripts/publish-release.mjs @@ -8,8 +8,8 @@ * published tarball has the same integrity is skipped, and a version whose * published tarball differs fails the run — that last case means the content * changed without a version bump. Skipping on identical integrity is what makes - * re-running the publish step over the same artifact safe, which matters here - * because a partial publication used to leave no way forward: republishing an + * re-running the publish step over the same artifact safe. Without the + * integrity skip, a partial publication has no way forward: republishing an * existing version fails permanently. * * Usage: `node scripts/publish-release.mjs [packed dir]`. diff --git a/package.json b/package.json index 3ec9a6c1c5..bec66ae4c9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "license": "MIT", "private": true, "type": "module", @@ -17,9 +17,10 @@ "website" ], "scripts": { - "build": "npm run build:lib && npm run build:web", + "build": "tsx scripts/build.ts", + "build:official": "tsx scripts/build.ts --profile official", "build:lib": "npm run build:lib:host && npm run build:lib:client", - "build:lib:host": "tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", + "build:lib:host": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc -b tsconfig.host.json && tsdown --env.DSH_BUILD_FACE host", "build:lib:client": "tsc -b tsconfig.client.json && tsdown --env.DSH_BUILD_FACE client", "build:web": "pnpm --filter @deepseek-ai/dsh-web-frontend run build", "clean": "tsx scripts/clean.ts", @@ -35,6 +36,8 @@ "test:coverage": "vitest run --coverage", "test:coverage:partitioned": "tsx scripts/run-coverage-partitions.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts", + "test:expected": "vitest run --config vitest.expected.config.ts", + "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts", "test:issue-management": "node .github/issue-management/policy.test.mjs", "test:snapshot": "vitest run --config vitest.snapshot.config.ts", "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update", @@ -71,6 +74,7 @@ "verify-doc-site-fragments": "tsx scripts/verify-doc-site-fragments.ts", "verify-public-repository-links": "tsx scripts/verify-public-repository-links.ts", "verify-doc-refs": "tsx scripts/verify-doc-refs.ts", + "verify-subsystem-pages": "tsx scripts/verify-subsystem-pages.ts", "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-dsh-package-licenses": "tsx scripts/verify-dsh-package-licenses.ts", "verify-config-source-ownership": "tsx scripts/verify-config-source-ownership.ts", @@ -85,12 +89,13 @@ "verify-skill-invocation-metadata": "tsx scripts/verify-skill-invocation-metadata.ts", "verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", + "test:docs": "tsx scripts/run-gates.ts doc-quick", "resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve", "gen-translation-brief": "tsx scripts/gen-translation-brief.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "docs:dev": "pnpm --filter @deepseek-ai/website run dev", - "docs:build": "pnpm --filter @deepseek-ai/website run build && pnpm run verify-doc-site-fragments", - "docs:build:mpa": "pnpm --filter @deepseek-ai/website exec vitepress build . --mpa && pnpm run verify-doc-site-fragments", + "docs:build": "tsx website/build.ts && pnpm run verify-doc-site-fragments", + "docs:build:mpa": "tsx website/build.ts --mpa && pnpm run verify-doc-site-fragments", "docs:preview": "pnpm --filter @deepseek-ai/website run preview", "docs:check": "pnpm exec vitest run scripts/project-doc-site.spec.ts scripts/verify-doc-site-fragments.spec.ts && pnpm run docs:build", "website:dev": "pnpm run docs:dev", @@ -99,7 +104,9 @@ "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "verify-optional-dependency-imports": "tsx scripts/verify-optional-dependency-imports.ts", "verify-runtime-closure": "tsx scripts/verify-runtime-closure.ts", + "verify-application-entrypoints": "tsx scripts/verify-application-entrypoints.ts", "verify-client-packages": "tsx scripts/verify-client-packages.ts", + "verify-client-ui-i18n": "tsx scripts/verify-client-ui-i18n.ts", "verify-vendored-links": "tsx scripts/verify-vendored-links.ts", "verify-cordis-config": "tsx scripts/verify-cordis-config.ts", "rescope-vendor": "tsx scripts/rescope-vendor.ts", @@ -111,6 +118,7 @@ "verify-cordis-api": "tsx scripts/gen-cordis-api.ts --check", "gen-client-catalog": "tsx scripts/gen-client-catalog.ts", "gen-cordis-inspect-catalog": "tsx scripts/gen-cordis-inspect-catalog.ts", + "verify-cordis-inspect-catalog": "tsx scripts/gen-cordis-inspect-catalog.ts --check", "verify-client-catalog": "tsx scripts/gen-client-catalog.ts --check", "verify-export-jsdoc": "tsx scripts/verify-export-jsdoc.ts", "gen-tool-catalog": "tsx scripts/gen-tool-catalog.ts", @@ -129,7 +137,7 @@ "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", "doc-sync": "tsx scripts/run-gates.ts doc-sync", - "hygiene": "pnpm run rescope-vendor:check && pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-dsh-package-licenses && pnpm run verify-package-invariants && pnpm run verify-built-package-invariants && pnpm run verify-cordis-config && pnpm run verify-node-next-types && pnpm run verify-optional-dependency-imports && pnpm run verify-runtime-closure && pnpm run verify-client-packages && pnpm run verify-vendored-links", + "hygiene": "tsx scripts/run-gates.ts hygiene", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "release:dsh": "tsx scripts/release/bump.ts --family dsh", "release:vendor": "tsx scripts/release/bump.ts --family vendor", @@ -139,15 +147,13 @@ "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", - "demo:cordis": "node scripts/demo-cordis.mjs", - "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", "mock:llm": "node --import tsx packages/test-support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" }, "devDependencies": { - "@agentclientprotocol/sdk": "0.25.1", "@deepseek-ai/dsh-tool-session-query": "workspace:^", + "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@stylistic/eslint-plugin": "^5.10.0", "@testing-library/dom": "^10.4.1", "@testing-library/react": "^16.3.2", diff --git a/packages/AGENTS.md b/packages/AGENTS.md index eb75c9acb8..e753147a72 100644 --- a/packages/AGENTS.md +++ b/packages/AGENTS.md @@ -22,6 +22,6 @@ These package-specific rules supplement the repo-wide [conventions](../AGENTS.md - **Package tsconfig:** extends `tsconfig.base.json` (Client: `tsconfig.base.client.json`), uses `rootDir: src`, `outDir: lib/types`, and references each workspace dependency plus `runtime-diagnostics/invariants`; registers in exactly one aggregate. Only `api/remotes` splits for generated contracts; ordinary two-entry Client plugins do not ([layout](../docs/development.md#typescript-project-layout)). - `src/types.ts` contains only types — no runtime code. - Tests live at package level under `tests/`, not `src/__tests__/`. -- A package's README and JSDoc are part of the change: altered behavior (config keys, defaults, error codes, wire fields) updates them in the same commit. `doc-sync` gates what it can; apply [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md) for complete, concise prose and verify accuracy against code. +- Update package README and JSDoc contracts in the same commit as behavior, and verify them against code with [dsh-prose-standard](../.agents/skills/dsh-prose-standard/SKILL.md). Group READMEs declare subsystem ownership through a canonical English page link or justified [exemption](../scripts/verify-subsystem-pages.ts). - Package READMEs document model, token, and KV-cache effects using the [canonical Model Experience format](../docs/cookbook/adding-a-package.md#4-write-the-package-readme). - Package READMEs put durable consumer gaps and non-obvious maintainer constraints under `## Known Limitations and Deferred Work`; ordinary cleanup stays in its TODO or Agent Note. Packages with none use a justified [allowlist entry](../scripts/verify-package-readme-limitations.ts) ([rationale](../.agents/notes/implemented/process/2026-07-10-readme-known-limitations-gate.md)). diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index ee425f39d7..7816391ff8 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: a410d7148d14503a61edb9c4848b521552050ca4 -README.zh.md: 780d1356f2095c7dcc526e5cdac8994267e4a460 +README.md: c3dcbd3032e3eadad21b640f3a614ef5a1a6c6e7 +README.zh.md: 80518ef0de7c2022db45aefa411f507870cf09aa diff --git a/packages/README.md b/packages/README.md index a410d7148d..c3dcbd3032 100644 --- a/packages/README.md +++ b/packages/README.md @@ -1,70 +1,115 @@ +--- +description: "The DeepSeek Harness package workspace: how the npm packages under packages/ are grouped, what each group owns, and the conventions that bind them." +kind: "package-group" +--- + # Packages English | [中文](README.zh.md) -npm scope: `@deepseek-ai/dsh-*`; Cordis `Service` subclasses and function plugins contribute through `ctx.effect()`, `ctx.on()`, or `ctx.waterfall()`. Rules: [package](AGENTS.md), [root](../AGENTS.md#conventions). +## Summary -## Hierarchy +The harness is assembled from npm packages under `packages/`, grouped by capability family: sessions and the agent loop, model-facing tools, shell and filesystem execution, web access, subagents, and the rest. Use this page as the top-level map: find the owning group, then open its README for the package list. Every package is scoped `@deepseek-ai/dsh-*` and lives in exactly one group; each group README is the authoritative package map for its family. -Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Group READMEs own package/ctx-key maps.** +## Table of Contents -| Group | Role | Release expectation | -|---|---|---| -| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | Product — stable API | -| [`api/`](api/README.md) | Remote BFF assembly and Typert RPC gateway | Product — stable API | -| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | Product — stable API | -| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | Product — stable API | -| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | Product — stable API | -| [`feedback/`](feedback/README.md) | Human feedback | Product — stable API | -| [`identity/`](identity/README.md) | Shared anonymous identity | Product — stable API | -| [`llm/`](llm/README.md) | LLM capability family: the abstract service + provider adapters | Product — stable API | -| [`e2b/`](e2b/README.md) | E2B providers | POC | -| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | Product — stable API | -| [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tool | Product — stable API | -| [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, and model-facing tools | Product — stable API | -| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | Product — stable API | -| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | Product — stable API | -| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, bash-backed discovery tools | Product — stable API | -| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | Product — stable API | -| [`skill/`](skill/README.md) | Skill capability family: the provider registry, local provider, and model-facing catalog/loader | Product — stable API | -| [`compaction/`](compaction/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | Product — stable API | -| [`context/`](context/README.md) | Model-visible request context, including workspace instructions and time context | Product — stable API | -| [`subagent/`](subagent/README.md) | Subagent capability family: the provider-registry contract and the model-facing delegation tool | Product — stable API | -| [`jobs/`](jobs/README.md) | Generic background-job runtime and model-facing `job_*` control tools | Product — stable API | -| [`experimental/`](experimental/README.md) | Private prototypes and internal-only plugins | Unreleased | -| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | Product — stable API | -| [`web/`](web/README.md) | Web capability family: seam, search/fetch provider impls, and the model-facing web tools | Product — stable API | -| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | Product — stable API | -| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | Product — stable API | -| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | Product — stable API | -| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | Product — stable API | -| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable API | -| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable API | -| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable API | -| [`extensions/`](extensions/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable API | -| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable API | -| [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable API | -| [`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 API | -| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | Product — stable API | -| [`credentials/`](credentials/README.md) | Credential-reference seam + env-over-`.env` provider | Product — stable API | -| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | Product — stable API | -| [`workspace/`](workspace/README.md) | Workspace entity | Product — stable API | -| [`sdk/`](sdk/README.md) | Out-of-process runtime SDK: JSON-RPC protocol, TypeScript client, and server plugin | Product — stable API | -| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | Product — stable API | -| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | Product — stable API | -| [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable API | -| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable API | -| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable API | -| [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | -| [`test-support/`](test-support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | -| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | +- [Package groups](#package-groups) +- [Release expectations](#release-expectations) +- [Dependencies](#dependencies) +- [Package README contracts](#package-readme-contracts) +- [Dev Note](#dev-note) -New packages join existing groups; new groups update their README and this table. +----- + +## Package groups + +Every package lives in exactly one group; new packages join existing groups, and a new group updates its own README and this table. + +| Group | Role | +|---|---| +| [`core/`](core/README.md) | Product API spine: sessions, prompts, tools, agent services, and the concrete loop | +| [`api/`](api/README.md) | Remote BFF assembly and Typert RPC gateway | +| [`typert/`](typert/README.md) | Type graph generation, artifact loading, and runtime registry | +| [`goal/`](goal/README.md) | Same-session goal persistence and lifecycle | +| [`schedule/`](schedule/README.md) | Session-local scheduled follow-ups | +| [`feedback/`](feedback/README.md) | Human feedback capture and command | +| [`identity/`](identity/README.md) | Shared anonymous identity | +| [`llm/`](llm/README.md) | LLM capability family: abstract service + provider adapters | +| [`e2b/`](e2b/README.md) | E2B remote-runtime providers | +| [`subprocess/`](subprocess/README.md) | Subprocess capability family: Service Definition + local process-tree provider | +| [`shell/`](shell/README.md) | Bash capability family: executor seam, local impl, model-facing tools | +| [`terminal/`](terminal/README.md) | Persistent PTY capability family: owner-scoped sessions, local implementation, model-facing tools | +| [`code-runtime/`](code-runtime/README.md) | Code-execution capability family: Service Definition + worker-thread provider + Code Mode Consumer | +| [`sandbox/`](sandbox/README.md) | Process-confinement seam; bwrap/Landlock/Seatbelt backends | +| [`fs/`](fs/README.md) | Filesystem capability family: seam, local impl, model-facing file tools, discovery tools | +| [`lsp/`](lsp/README.md) | LSP capability family: seam, generic stdio provider, and the `lsp` tool | +| [`skill/`](skill/README.md) | Skill capability family: provider registry, local provider, model-facing catalog/loader | +| [`compaction/`](compaction/README.md) | Compaction capability family: Service Definition + basic provider + command Consumer | +| [`context/`](context/README.md) | Model-visible request context: workspace instructions, time context, references | +| [`subagent/`](subagent/README.md) | Subagent capability family: provider-registry contract and model-facing delegation tools | +| [`jobs/`](jobs/README.md) | Generic background-job runtime and model-facing job control tools | +| [`experimental/`](experimental/README.md) | Private prototypes and internal-only plugins | +| [`workflow/`](workflow/README.md) | Workflow seam, worker-thread engine, and model-facing `workflow`/`ralph` tools | +| [`webhook/`](webhook/README.md) | Verified external events, trusted rules, and fire-and-forget Workspace Sessions | +| [`web/`](web/README.md) | Web capability family: seam, search/fetch providers, model-facing web tools | +| [`attachment/`](attachment/README.md) | Durable attachment identity, validation, local content-addressed storage | +| [`spill/`](spill/README.md) | Spill capability family: storage seam, local impl, tool-result spill policy | +| [`todo/`](todo/README.md) | The model-facing `todo_write` tool | +| [`plan/`](plan/README.md) | Plan collaboration state with a direct entry command and reviewed exit | +| [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | +| [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | +| [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | +| [`extensions/`](extensions/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written mount/unmount | +| [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | +| [`session/`](session/README.md) | Durable session data plane: persistence seam + backends, projection seam, log-backed titles, session reporting | +| [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, semantic filtering, SQLite full-text search | +| [`settings/`](settings/README.md) | User-settings seam + file-backed provider | +| [`credentials/`](credentials/README.md) | Credential-reference and credential-record seam + env-over-`.env` provider + authorization flows that ask a human | +| [`storage/`](storage/README.md) | Non-session storage hub + backends + domain form | +| [`workspace/`](workspace/README.md) | Workspace entity | +| [`sdk/`](sdk/README.md) | Out-of-process SDK: JSON-RPC protocol and TypeScript client/server | +| [`acp/`](acp/README.md) | Automation-only Agent Client Protocol server | +| [`interaction/`](interaction/README.md) | Human-collaboration plane: approval/interaction seams, permission preset, commands, ask-user tool | +| [`boot/`](boot/README.md) | Shared app-bin boot glue | +| [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | +| [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | +| [`examples/`](examples/README.md) | Reusable composition bundles for tests and custom deployments | +| [`test-support/`](test-support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | +| [`runtime-diagnostics/`](runtime-diagnostics/README.md) | Runtime diagnostics: package-owned invariant checks and reports | +| [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, home/path helpers, timeout, retention) | + +----- + + +## Release expectations + +Most groups are product — stable API. The exceptions: `e2b/` is a POC, `experimental/` is unreleased, and `examples/`, `test-support/`, `runtime-diagnostics/`, and `util/` are support with lower compatibility expectations. + +----- + + ## Dependencies The dependency graph is generated: [docs/module-graph.md](../docs/module-graph.md) (`pnpm run gen-module-graph`, freshness-gated in CI). **Extension plugins depend on Service Definitions, never concrete providers.** `dsh-agent-loop` is swappable; UI, hook, and tool plugins use `dsh-agent`. Composition bundles, including `dsh-agent-spine-demo`, may depend on spine plugins. Capabilities separate Service Definition / Service Provider / Consumer roles when they evolve independently; see [capability seams](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md). -Package READMEs cover purpose, APIs, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless on the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts). They also carry `## Known Limitations and Deferred Work` or use its [allowlist](../scripts/verify-package-readme-limitations.ts). +----- + + +## Package README contracts + +Every package README covers purpose, configuration, extension points, and [Model Experience](../docs/cookbook/adding-a-package.md#4-write-the-package-readme) unless the model-agnostic [omission allowlist](../scripts/verify-package-readme-model-experience.ts) exempts it. It also carries `## Known Limitations and Deferred Work` or uses its [allowlist](../scripts/verify-package-readme-limitations.ts). Package conventions — exports, service access, invariants, tests — live in [packages/AGENTS.md](AGENTS.md). + +----- + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/README.zh.md b/packages/README.zh.md index 780d1356f2..80518ef0de 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -1,70 +1,115 @@ +--- +description: "DeepSeek Harness 包工作区:packages/ 下的 npm 包如何分组、每个组负责什么,以及约束它们的约定。" +kind: "package-group" +--- + # 包 [English](README.md) | 中文 -npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通过 `ctx.effect()`、`ctx.on()` 或 `ctx.waterfall()` 注册。规则见[包](AGENTS.md)与[根规则](../AGENTS.md#conventions)。 +## 概述 -## 层级结构 +harness 由 `packages/` 下的 npm 包组装而成,按能力系列分组:会话与 agent 循环、面向模型的工具、shell 与文件系统执行、Web 访问、subagent 等等。把本页当作顶层地图使用:先找到拥有某能力的组,再打开其 README 查看包列表。每个包都以 `@deepseek-ai/dsh-*` 为作用域、只属于一个组;每个组的 README 都是该能力系列的权威包映射。 -包按组置于 `packages///`;包名仍为 `@deepseek-ai/dsh-`。**组 README 负责包/ctx 键映射。** +## 目录 -| 组 | 职责 | 发布预期 | -|---|---|---| -| [`core/`](core/README.md) | 产品 API 主干:会话、提示词、工具、agent(智能体)服务与具体循环 | 产品:稳定 API | -| [`api/`](api/README.md) | Remote BFF 装配与 Typert RPC 网关 | 产品:稳定 API | -| [`typert/`](typert/README.md) | 类型图生成、产物加载与运行时注册表 | 产品:稳定 API | -| [`goal/`](goal/README.md) | 同会话 goal 的持久化与生命周期 | 产品:稳定 API | -| [`schedule/`](schedule/README.md) | 仅限会话内的定时后续操作 | 产品:稳定 API | -| [`feedback/`](feedback/README.md) | 人类反馈 | 产品:稳定 API | -| [`identity/`](identity/README.md) | 共享匿名身份 | 产品:稳定 API | -| [`llm/`](llm/README.md) | LLM(大语言模型)能力系列:抽象服务 + 提供方适配器 | 产品:稳定 API | -| [`e2b/`](e2b/README.md) | E2B 提供方 | POC | -| [`subprocess/`](subprocess/README.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | 产品:稳定 API | -| [`shell/`](shell/README.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | 产品:稳定 API | -| [`terminal/`](terminal/README.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现和面向模型的工具 | 产品:稳定 API | -| [`code-runtime/`](code-runtime/README.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | 产品:稳定 API | -| [`sandbox/`](sandbox/README.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | 产品:稳定 API | -| [`fs/`](fs/README.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、由 bash 支持的发现工具 | 产品:稳定 API | -| [`lsp/`](lsp/README.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | 产品:稳定 API | -| [`skill/`](skill/README.md) | skill(技能)能力系列:提供方注册表、本地提供方和面向模型的目录/loader | 产品:稳定 API | -| [`compaction/`](compaction/README.md) | 压缩(compaction)能力系列:Service Definition + 基础提供方 + 命令 Consumer | 产品:稳定 API | -| [`context/`](context/README.md) | 模型可见请求上下文,包括 workspace 指令和时间上下文 | 产品:稳定 API | -| [`subagent/`](subagent/README.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | 产品:稳定 API | -| [`jobs/`](jobs/README.md) | 通用后台任务运行时和面向模型的 `job_*` 控制工具 | 产品:稳定 API | -| [`experimental/`](experimental/README.md) | 私有原型与内部专用插件 | 不发布 | -| [`workflow/`](workflow/README.md) | 工作流 seam、worker 线程引擎和面向模型的 `workflow`/`ralph` 工具 | 产品:稳定 API | -| [`web/`](web/README.md) | Web 能力系列:seam、搜索/获取提供方实现和面向模型的 Web 工具 | 产品:稳定 API | -| [`attachment/`](attachment/README.md) | 持久附件标识、校验、本地内容寻址存储 | 产品:稳定 API | -| [`spill/`](spill/README.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | 产品:稳定 API | -| [`todo/`](todo/README.md) | 面向模型的 `todo_write` 工具 | 产品:稳定 API | -| [`plan/`](plan/README.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | 产品:稳定 API | -| [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定 API | -| [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定 API | -| [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定 API | -| [`extensions/`](extensions/README.md) | agent 运行时自修改:实时插件/服务检查和模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定 API | -| [`hooks/`](hooks/README.md) | 钩子桥接 + 共享的 Claude Code/Codex 线协议库 | 产品:稳定 API | -| [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、基于日志的标题、会话上报 | 产品:稳定 API | -| [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定 API | -| [`settings/`](settings/README.md) | 用户设置 seam + 基于文件的提供方 | 产品:稳定 API | -| [`credentials/`](credentials/README.md) | 凭据引用 seam + 环境变量优先于 `.env` 的提供方 | 产品:稳定 API | -| [`storage/`](storage/README.md) | 非会话存储中枢 + 后端 + 领域形式 | 产品:稳定 API | -| [`workspace/`](workspace/README.md) | Workspace 实体 | 产品:稳定 API | -| [`sdk/`](sdk/README.md) | 进程外运行时 SDK:JSON-RPC 协议、TypeScript 客户端和服务器插件 | 产品:稳定 API | -| [`acp/`](acp/README.md) | 仅面向自动化的 ACP(Agent Client Protocol)服务器 | 产品:稳定 API | -| [`interaction/`](interaction/README.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | 产品:稳定 API | -| [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定 API | -| [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定 API | -| [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定 API | -| [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI(命令行界面)/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | -| [`test-support/`](test-support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | -| [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、留存) | 支持:小型、稳定、无 harness 依赖 | +- [包分组](#package-groups) +- [发布预期](#release-expectations) +- [依赖](#dependencies) +- [包 README 约定](#package-readme-contracts) +- [开发备注](#dev-note) -新包加入现有组;新组更新其 README 和此表。 +----- + +## 包分组 + +每个包只属于一个组;新包加入现有组,新组则更新其自身 README 与本表。 + +| 组 | 职责 | +|---|---| +| [`core/`](core/README.zh.md) | 产品 API 主干:会话、提示词、工具、agent 服务与具体循环 | +| [`api/`](api/README.zh.md) | Remote BFF 装配与 Typert RPC 网关 | +| [`typert/`](typert/README.zh.md) | 类型图生成、产物加载与运行时注册表 | +| [`goal/`](goal/README.zh.md) | 同会话 goal 的持久化与生命周期 | +| [`schedule/`](schedule/README.zh.md) | 仅限会话内的定时后续操作 | +| [`feedback/`](feedback/README.zh.md) | 人类反馈的采集与命令 | +| [`identity/`](identity/README.zh.md) | 共享匿名身份 | +| [`llm/`](llm/README.zh.md) | LLM 能力系列:抽象服务 + 提供方适配器 | +| [`e2b/`](e2b/README.zh.md) | E2B 远程运行时提供方 | +| [`subprocess/`](subprocess/README.zh.md) | 子进程能力系列:Service Definition + 本地进程树提供方 | +| [`shell/`](shell/README.zh.md) | Bash 能力系列:执行器 seam、本地实现、面向模型的工具 | +| [`terminal/`](terminal/README.zh.md) | 持久 PTY 能力系列:限定所有者范围的会话、本地实现、面向模型的工具 | +| [`code-runtime/`](code-runtime/README.zh.md) | 代码执行能力系列:Service Definition + worker 线程提供方 + Code Mode Consumer | +| [`sandbox/`](sandbox/README.zh.md) | 进程限制 seam;bwrap/Landlock/Seatbelt 后端 | +| [`fs/`](fs/README.zh.md) | 文件系统能力系列:seam、本地实现、面向模型的文件工具、发现工具 | +| [`lsp/`](lsp/README.zh.md) | LSP 能力系列:seam、通用 stdio 提供方和 `lsp` 工具 | +| [`skill/`](skill/README.zh.md) | skill 能力系列:提供方注册表、本地提供方、面向模型的目录/loader | +| [`compaction/`](compaction/README.zh.md) | 压缩能力系列:Service Definition + 基础提供方 + 命令 Consumer | +| [`context/`](context/README.zh.md) | 模型可见请求上下文:workspace 指令、时间上下文、引用 | +| [`subagent/`](subagent/README.zh.md) | subagent 能力系列:提供方注册表约定和面向模型的委托工具 | +| [`jobs/`](jobs/README.zh.md) | 通用后台任务运行时和面向模型的作业控制工具 | +| [`experimental/`](experimental/README.zh.md) | 私有原型与内部专用插件 | +| [`workflow/`](workflow/README.zh.md) | 工作流 seam、worker 线程引擎、面向模型的 `workflow`/`ralph` 工具 | +| [`webhook/`](webhook/README.zh.md) | 已验证外部事件、受信规则与即发即弃 Workspace Session | +| [`web/`](web/README.zh.md) | Web 能力系列:seam、搜索/获取提供方、面向模型的 Web 工具 | +| [`attachment/`](attachment/README.zh.md) | 持久附件标识、校验、本地内容寻址存储 | +| [`spill/`](spill/README.zh.md) | spill 能力系列:存储 seam、本地实现、工具结果 spill 策略 | +| [`todo/`](todo/README.zh.md) | 面向模型的 `todo_write` 工具 | +| [`plan/`](plan/README.zh.md) | Plan 协作状态,提供直接进入命令与经评审的退出 | +| [`preset/`](preset/README.zh.md) | 由 preset `cordis.yml` 按会话组装 agent | +| [`guard/`](guard/README.zh.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | +| [`bundle/`](bundle/README.zh.md) | 可安装的 `dsh --profile` 补丁层 | +| [`extensions/`](extensions/README.zh.md) | agent 运行时自修改:实时插件/服务检查与模型所写挂载/卸载 | +| [`hooks/`](hooks/README.zh.md) | 钩子桥接 + 共享的 Claude Code / Codex 线协议库 | +| [`session/`](session/README.zh.md) | 持久会话数据平面:持久化 seam + 后端、投影 seam、基于日志的标题、会话上报 | +| [`session-query/`](session-query/README.zh.md) | 会话检索系列:逻辑语料库、有界读取、血缘、语义过滤、SQLite 全文搜索 | +| [`settings/`](settings/README.zh.md) | 用户设置 seam + 基于文件的提供方 | +| [`credentials/`](credentials/README.zh.md) | 凭据引用/记录 seam + 环境变量优先于 `.env` 的提供方 + 询问人类的授权 flow | +| [`storage/`](storage/README.zh.md) | 非会话存储中枢 + 后端 + 领域形式 | +| [`workspace/`](workspace/README.zh.md) | Workspace 实体 | +| [`sdk/`](sdk/README.zh.md) | 进程外 SDK:JSON-RPC 协议与 TypeScript 客户端/服务器 | +| [`acp/`](acp/README.zh.md) | 仅面向自动化的 Agent Client Protocol 服务器 | +| [`interaction/`](interaction/README.zh.md) | 人机协作平面:批准/交互 seam、权限预设、命令、询问用户的工具 | +| [`boot/`](boot/README.zh.md) | 共享的 app bin 启动粘合层 | +| [`host/`](host/README.zh.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | +| [`client/`](client/README.zh.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | +| [`examples/`](examples/README.zh.md) | 供测试与自定义部署复用的组合包 | +| [`test-support/`](test-support/README.zh.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | +| [`runtime-diagnostics/`](runtime-diagnostics/README.zh.md) | 运行时诊断:按包归属的运行时不变式检查与报告 | +| [`util/`](util/README.zh.md) | 组间共享的低层零依赖工具(`Branded`、home/路径辅助函数、超时、留存) | + +----- + + +## 发布预期 + +大多数组是产品——稳定 API。例外:`e2b/` 是 POC,`experimental/` 不发布,`examples/`、`test-support/`、`runtime-diagnostics/` 与 `util/` 是兼容性预期较低的支持组。 + +----- + + ## 依赖 -依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。 +依赖图由工具生成:[docs/module-graph.md](../docs/module-graph.zh.md)(`pnpm run gen-module-graph`,CI 中有新鲜度门禁)。 -**扩展插件依赖 Service Definition,绝不依赖具体提供方。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力会将需要独立演进的 Service Definition/Service Provider/Consumer 角色分离;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)。 +**扩展插件依赖 Service Definition,绝不依赖具体提供方。** `dsh-agent-loop` 可替换;UI、钩子和工具插件使用 `dsh-agent`。包括 `dsh-agent-spine-demo` 在内的组合包可以依赖主干插件。能力在需要独立演进时分离 Service Definition / Service Provider / Consumer 角色;详见[能力 seam](../.agents/notes/implemented/architecture/2026-06-13-capability-seams.zh.md)。 -包 README 覆盖用途、API、扩展点和[模型体验](../docs/cookbook/adding-a-package.md#4-write-the-package-readme);列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它们还要包含 `## Known Limitations and Deferred Work`,或列入其[允许清单](../scripts/verify-package-readme-limitations.ts)。 +----- + + +## 包 README 约定 + +每个包 README 都覆盖用途、配置、扩展点与[模型体验](../docs/cookbook/adding-a-package.zh.md#4-write-the-package-readme),列入模型无关[省略允许清单](../scripts/verify-package-readme-model-experience.ts)的包除外。它还要包含 `## Known Limitations and Deferred Work`,或列入其[允许清单](../scripts/verify-package-readme-limitations.ts)。包约定——导出、服务访问、不变式、测试——见 [packages/AGENTS.md](AGENTS.md)。 + +----- + + +## 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/acp/README.i18n.yaml b/packages/acp/README.i18n.yaml index 08fe41b6ab..a5628d5807 100644 --- a/packages/acp/README.i18n.yaml +++ b/packages/acp/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/acp/README.md -README.md: 97af6d164b265bf0e98e3c9f5a444cffad4face5 -README.zh.md: fb9f419e23aef5b3bdf0bdd486b331eb2f3f236a +README.md: 20640ec4bdc5e9e9d2ac51e1f54fb2f587652e1c +README.zh.md: 09fd7a3f7d40ff91d3e6516bf1c4c2075affc76f diff --git a/packages/acp/README.md b/packages/acp/README.md index 97af6d164b..20640ec4bd 100644 --- a/packages/acp/README.md +++ b/packages/acp/README.md @@ -1,11 +1,41 @@ +--- +description: "The Agent Client Protocol package group: the automation-only server that exposes fresh harness agents to programmatic clients over JSON-RPC stdio." +kind: "package-group" +--- + # acp/ — Agent Client Protocol automation English | [中文](README.zh.md) -The ACP group exposes harness agents to programmatic clients over the Agent Client Protocol. It is an interoperability transport, not a presentation or human-interaction layer; the matching out-of-process subagent *client* lives in [`subagent/subagent-acp`](../subagent/subagent-acp/README.md) because it implements the subagent provider interface. +## Summary + +The acp group provides one package: a server that lets programs and automation run persistent DeepSeek Harness agents over the standard Agent Client Protocol. A client can create, list, resume, and close sessions; attach standard MCP servers; select model options; send text and image prompts; receive semantic updates; answer permission prompts; and cancel work without a human in the loop. The matching client for spawning such a server from another harness lives in `subagent/subagent-acp`. This page maps the group; the package README owns the per-package contract. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages | Package | Role | |---|---| -| [`acp/`](acp/README.md) | Automation-only ACP server. | +| [`acp/`](acp/README.md) | Lets programs manage persistent agents over ACP, attach MCP servers, select model options, prompt and cancel work, and receive semantic updates | -The server contract is documented in [`acp/README.md`](acp/README.md). +----- + + +## Related documentation + +- [dsh-subagent-acp](../subagent/subagent-acp/README.md) — the out-of-process ACP client that spawns and drives this server. +- [ACP as an automation-only protocol](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md) — the design record for the automation contract and its wire boundaries. +- [Multiplex concurrent ACP sessions over one connection](../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md) — per-session isolation, ownership, and teardown decisions. + + +## Dev Note + +None. diff --git a/packages/acp/README.zh.md b/packages/acp/README.zh.md index fb9f419e23..09fd7a3f7d 100644 --- a/packages/acp/README.zh.md +++ b/packages/acp/README.zh.md @@ -1,11 +1,41 @@ -# acp/:Agent Client Protocol 自动化 +--- +description: "ACP(Agent Client Protocol)包组:通过 JSON-RPC stdio 将全新 harness agent 暴露给程序化客户端的仅自动化服务器。" +kind: "package-group" +--- + +# acp/ — Agent Client Protocol 自动化 [English](README.md) | 中文 -ACP(Agent Client Protocol)组通过该协议将 harness 中的 agent(智能体)公开给程序化客户端。它是互操作传输层,不是展示或人机交互层;配对的进程外 subagent *客户端*在 [`subagent/subagent-acp`](../subagent/subagent-acp/README.md),因为它实现的是 subagent 提供方接口。 +## 概述 + +acp 组提供一个包:一台服务器,让程序与自动化可以通过标准 Agent Client Protocol 运行持久 DeepSeek Harness agent。客户端可以创建、列出、恢复与关闭会话,挂载标准 MCP 服务器,选择模型选项,发送文本与图片提示词,接收语义更新,响应权限提示并取消工作——无需人类参与。从另一个 harness 启动这种服务器的配套客户端位于 `subagent/subagent-acp`。本页是组的映射;包 README 负责各自的包级约定。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 | 包 | 职责 | |---|---| -| [`acp/`](acp/README.md) | 仅面向自动化的 ACP 服务器。 | +| [`acp/`](acp/README.zh.md) | 让程序通过 ACP 管理持久 agent、挂载 MCP 服务器、选择模型选项、发送或取消工作并接收语义更新 | -服务器约定见 [`acp/README.md`](acp/README.md)。 +----- + + +## 相关文档 + +- [dsh-subagent-acp](../subagent/subagent-acp/README.zh.md)——spawn 并驱动本服务器的进程外 ACP 客户端。 +- [ACP 作为仅面向自动化的协议](../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md)——自动化约定及其协议边界的决策记录。 +- [在单个连接上多路复用并发 ACP 会话](../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md)——按会话隔离、归属与清理决策。 + + +## 开发备注 + +无。 diff --git a/packages/acp/acp/README.i18n.yaml b/packages/acp/acp/README.i18n.yaml index 37e6230aba..304b9e8d6d 100644 --- a/packages/acp/acp/README.i18n.yaml +++ b/packages/acp/acp/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/acp/acp/README.md -README.md: aaabb0c824e12c250851985e92c0473f147e8efa -README.zh.md: e722dbf06404f453dc746c7daf61d3e7b5b68fc2 +README.md: 5ba5b740dd35a99b57c82c4ee18753781742b7a7 +README.zh.md: 80391c879109fdc44e9b396ee4ca0016316dbad7 diff --git a/packages/acp/acp/README.md b/packages/acp/acp/README.md index aaabb0c824..5ba5b740dd 100644 --- a/packages/acp/acp/README.md +++ b/packages/acp/acp/README.md @@ -1,63 +1,149 @@ +--- +description: "Automation-only Agent Client Protocol server for programmatic clients and maintainers driving DeepSeek Harness agents over JSON-RPC stdio." +kind: "package-reference" +--- + # @deepseek-ai/dsh-acp English | [中文](README.zh.md) -Automation-only [Agent Client Protocol](https://agentclientprotocol.com) server over JSON-RPC stdio. Programmatic clients create fresh harness agents, send text/image prompts, collect committed assistant text/images, resolve one-shot permission requests by policy, and cancel work. The primary in-repository client is [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md). +## Summary -This package is a transport adapter, not a UI integration or a capability seam. It does not expose editor navigation, transcript replay, commands, modes, configuration pickers, elicitation, reasoning, plans, titles, or tool presentation. Interactive rendering and human questions belong to the Web host and client modules. +`dsh-acp` lets trusted programs drive persistent DeepSeek Harness agents over the standard [Agent Client Protocol](https://agentclientprotocol.com): create or resume sessions, list resumable sessions, attach standard MCP servers, select a model and reasoning effort, prompt or cancel work, receive semantic execution updates, and close one session without affecting others. It is built for automation — out-of-process subagents, test runners, and scripted controllers — rather than the DSH user interface: it emits standard ACP messages, thoughts, generic tool lifecycle, configuration, and context usage, never private DSH presentation data or methods. Session persistence enables list, resume, and close across process restarts, while deletion, fork, transcript replay, additional directories, and interactive UI surfaces remain unsupported. The repository's own ACP client is `dsh-subagent-acp`, and `pnpm dsh --profile acp` starts a ready-to-use server. Setup and usage come first; the implementation details live in a collapsible developer section below. -## Plugin +## Table of Contents -`apply(ctx, config)` opens an `AgentSideConnection` on stdin/stdout and drives `ctx.agents`. Stdout is reserved for protocol frames. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -| Config | Default | Meaning | +----- + + +## Use this package + +Use this package when a script, test runner, or another harness needs to run agent work end to end through a standard automation protocol. The common path is: start the server, create or resume a session, optionally mount MCP servers and select model options, send a prompt, consume semantic updates, and close the session. + +### When to choose it + +Choose it when automation should own the interaction: an out-of-process subagent, test runner, or scripted controller that manages persistent sessions, tools, model selection, and permissions. Avoid it when a human needs DSH-specific presentation cards, plans, titles, todos, terminal views, or elicitation; this server intentionally exposes only the standard ACP v1 surface. + +### Minimal configuration + +Every session the server creates uses the provider and model configured here. Both fields are optional so another agent or request listener can supply them; the runnable demo composition sets both. Stdout carries only protocol traffic, so keep logging off it. + +```yaml +- name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-pro +``` + +| Field | Default | Meaning | |---|---|---| -| `provider` | — | Initial provider route for every created agent. | -| `model` | — | Initial model for every created agent. | +| `provider` | — | Provider route for every session's agent | +| `model` | — | Model for every session's agent | +| `sessionListPageSize` | `100` | Maximum summaries returned in one `session/list` page | -Both fields are optional so another agent/request listener may supply the target. The runnable ACP composition requires both. +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-acp) is the exhaustive source for every accepted field and its JSDoc. -## Protocol contract +### Start a server -| Method | Behavior | +`pnpm dsh --profile acp` starts the shipped stdio server. The `acp` profile mounts session persistence, so clients can list, resume, and close persistent sessions. [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) starts the same profile for out-of-process delegation. + + +### Protocol contract + +One connection can run several sessions at once, each independent. The calls a client makes: + +| Call | What you get | |---|---| -| `initialize` | Negotiates the supported version. Image prompts are advertised only when a durable attachment store is mounted and the configured exact provider/model resolves with explicit image input; audio and embedded context stay false. No session, editor, terminal, filesystem, or MCP capability is advertised. | -| `authenticate` | No-op because the server advertises no authentication methods. | -| `session/new` | Creates a fresh agent with an absolute primary `cwd`; empty `additionalDirectories` and `mcpServers` are accepted, non-empty values reject. | -| `session/prompt` | Preserves ordered text and supported inline image blocks, renders resource links as bracketed textual references, and rejects audio, embedded resources, malformed/empty input, or an image when capability was not advertised. It validates the whole image batch and rechecks the session's latest exact route before any save, commits every image before the user event, permits one in-flight request per session, and waits for admission plus, once queued, whole-Agent idle and ordered output delivery. Normal quiescence reports `end_turn`; explicit ACP cancellation, disposal, or a prompt whose admission was discarded (a turnless slot) reports `cancelled`. | -| `session/cancel` | Marks and aborts any in-progress admission without cancelling or waiting for unrelated Agent work; once this prompt has entered the Agent inbox, it cancels the addressed Agent and waits for the owned interval to quiesce. No late user message is published and the prompt settles as `cancelled`. With no in-flight prompt it cancels autonomous work; unknown ids are no-ops. | -| `session/update` | Emits one `agent_message_chunk` per non-empty text or image block in a committed `assistant/message`, preserving order. Images are re-read and integrity-verified before inline base64 delivery. Raw deltas and non-message events are omitted. | -| `session/request_permission` | Offers one-shot allow/reject choices for bridge-owned approval requests carrying a tool call id. Clients may answer automatically. | +| `initialize` | Stable ACP v1 plus `session/list`, `session/resume`, `session/close`, and Streamable HTTP MCP support; image prompts only when the durable attachment store and configured exact route support them. | +| `authenticate` | Immediate success; the server requires no authentication. | +| `session/new` | A fresh persistent agent whose absolute workspace and stdio or HTTP MCP servers are validated before publication, plus its complete configuration-option state. | +| `session/list` | Deterministic newest-first pages of persisted, resumable root sessions; an optional absolute `cwd` filter uses physical-directory identity where possible. | +| `session/resume` | A persisted inactive session whose canonical workspace is verified before composition; its log is restored without replaying old updates. | +| `session/close` | Quiescent cancellation, update draining, descendant disposal, persistence flush, and disposal of only the addressed Agent scope. | +| `session/set_config_option` | A serialized update to the advertised `model` or `reasoning_effort`, returning the complete resulting state. | +| `session/prompt` | Ordered text, resource links, and supported images, one prompt at a time per session; settlement follows Agent idle and ordered update delivery. | +| `session/cancel` / `$/cancel_request` | The prompt-owned cancellation path; without an ACP prompt in flight it cancels autonomous work, while unknown session ids are no-ops. | +| `session/update` | Committed assistant messages and thoughts, generic tool lifecycle, configuration changes, and context usage, serialized per session. | +| `session/request_permission` | A permission prompt with one-shot allow/reject choices; your client can answer automatically. | -One connection may own several sessions. The bridge keys records by branded session id and checks exact agent identity before routing events or permission requests. Each session has an independent prompt slot, workspace, cancellation path, and disposer. +Session configuration offers opaque provider/model choices from the live LLM service catalog and a `reasoning_effort` selector when the exact model declares one. A prompt snapshots that selection before asynchronous image admission and pins it across every model step in that turn; a concurrent option change applies to the next turn. ACP clients are trusted controllers: stdio MCP entries authorize their absolute commands and environment, HTTP entries authorize their absolute HTTP(S) URLs and headers, and any initial connection or discovery failure rolls back the unpublished Agent. Unsupported surfaces are omitted or reject: `session/load`, deletion, fork, additional directories, SSE or ACP-transport MCP, modes, commands, plans, terminals, client filesystem operations, and elicitation. -Committed-message output intentionally trades token-by-token latency for a clean automation result. Uncommitted provider chunks and retry attempts cannot leak partial text or images; reasoning and tool activity remain in the session log for observability through other interfaces. Per-session delivery is serialized because attachment reads are asynchronous, and a missing or corrupt committed image fails the prompt response instead of emitting a placeholder. +----- -## Lifecycle + +## Understand the implementation -Client disconnect and Cordis disposal share one memoized teardown. The bridge first rejects new sessions and prompts, cancels and quiesces prompt admission, agent activity, and ordered output delivery, then drains continuable descendants only below this connection's exact owned Agents before disposing those handles in parallel and awaiting every result before reporting any failure. Other frontends sharing the Context retain their continuable forests and admission. An ACP-only plugin reload therefore leaves no orphan agent. +
+Implementation internals — click to expand -ACP requires each prompt response to carry a `stopReason`, but the bridge does not claim a prompt-specific turn outcome. The operation interval starts when the prompt enters the Agent inbox and ends after admission, whole-Agent idle, and ordered output delivery all quiesce; failures from unrelated Agent work before that inbox receipt are not attributed to the prompt. Committed assistant messages stream across the owned interval, and steering or injected work may contribute before idle. Settlement precedence is explicit cancellation, output-delivery failure, interval-wide Agent failure, then the correlated turn ending. Token-limit endings settle as `end_turn`; a correlated model error rejects only at the same quiescence boundary. +This section explains how the server realizes the behavior above and points at the code that implements it; the observable behavior is fully covered in [Use this package](#use-this-package). -## Running +### Design philosophy -`pnpm --dir /path/to/deepseek-harness run demo:acp` boots the repository's automation server composition. A parent harness can spawn it through [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md); other ACP clients need only the core methods above. +The server is an automation transport with an intentionally standard public protocol. Three commitments shape it: +- **Standard semantic updates only.** The wire carries committed messages and thoughts, generic tool lifecycle, configuration, and context usage; raw provider deltas, retry attempts, DSH presentation data, and unsupported content stay off the wire. +- **Truthful capability and configuration state.** `initialize` advertises only mounted support, topology changes publish complete configuration options, and a prompt pins the exact route it admitted. +- **Quiescence before settlement.** Prompt and close operations settle only after their owned admission, Agent activity, ordered updates, descendants, persistence, and disposal have reached the required terminal state. + +The decision history lives in the [ACP as an automation-only protocol note](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md) and the [multi-session note](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md). + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `AgentSideConnection` wiring, per-session records, admission and settlement, teardown | +| [`src/content.ts`](src/content.ts) | Wire-content admission and projection: image validation, route recheck, prompt reconstruction, assistant block conversion | +| [`src/codec.ts`](src/codec.ts) | Pure turn-ending to ACP `stopReason` mapping | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; this transport owns no durable package-local event stream) | + +### Admission and prompt settlement + +Each session permits one in-flight prompt. Admission validates the whole prompt batch, snapshots the selected route, rechecks the exact Agent identity and image capability, persists image attachments, and only then queues the user message — a cancellation that wins admission never enqueues a late turn. Once queued, the session module associates the snapshot with the inbox message until claim and pins the same provider, model, and reasoning effort across prompt variables and every model step in that turn. Per-session update delivery is serialized; committed images are re-read and integrity-verified, so a missing or corrupt image fails the correlated prompt instead of emitting a placeholder. Settlement precedence is explicit cancellation, committed-output failure, interval-wide Agent failure, then the correlated turn ending. + +### Teardown and connection ownership + +Each session module owns its Agent handle, MCP mounts, future and turn-pinned model selections, prompt slot, update chain, and memoized close operation. Explicit close, client disconnect, and Cordis disposal use the same quiescent teardown: stop new work, cancel prompt admission and Agent activity, drain committed updates, dispose continuable descendants child-first, flush persistence, and release the owned Agent scope. A session close leaves persisted state available for list and resume, and other sessions or frontends sharing the Context remain untouched. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the matching client to the design records behind the automation contract. + +- [dsh-subagent-acp](../../subagent/subagent-acp/README.md) — the out-of-process ACP client that spawns and drives this server. +- [ACP as an automation-only protocol](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.md) — the design record for the automation contract and its wire boundaries. +- [Multiplex concurrent ACP sessions over one connection](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.md) — per-session isolation, ownership, and teardown decisions. +- [Extension cookbook](../../../docs/cookbook/extension-cookbook.md) — this package as the automation-only worked example for extension authors. + +----- + + ## Model Experience -### Prompt text and images +### Prompt content #### What the model sees -`session/prompt` preserves text/image order in one user message; adjacent text is concatenated, and a resource link appears as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Inline image base64 is discarded after batch admission, so the durable message contains only verified attachment references. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request. +`session/prompt` preserves text and image order in one user message: adjacent text concatenates, and a resource link appears as a bracketed `[resource_link name=… uri=…]` reference the model may open with its own tools. Inline image base64 is discarded after batch admission, so the durable message contains only verified attachment references. Protocol metadata, client capabilities, permission choices, and session ids never enter the model request. #### Token effect -Prompt tokens and image charges are data-dependent and remain in that session's history until compaction. Concurrent ACP sessions retain independent contexts. +Prompt content, tool calls/results, and durable image references remain in that session until compaction. Concurrent sessions retain independent contexts. #### KV Cache effect -Append-only; the new user message follows the reusable request prefix and does not invalidate prior cache entries. +Append-only while the selected route and assembled prefix stay unchanged. A model change starts the next ACP turn on the new route. ### Permission decisions @@ -75,7 +161,22 @@ Append-only through the owning tool result. ## Known Limitations and Deferred Work -- **Fresh sessions only** — load, list, resume, delete, and fork are unsupported. -- **Raster images and one workspace only** — image prompts require a durable store plus an exact route that declares image input; only PNG, JPEG, WebP, and GIF are accepted. Audio, embedded resources, non-empty additional directories, and MCP servers reject; resource links flatten to textual references rather than fetched content. -- **Committed answers only** — live progress, reasoning, tool activity, plans, titles, and usage stay off the wire. -- **Connection-owned lifetime** — one connection releases all of its sessions; per-session close is not implemented. + + + +These limits define when this package is a poor fit or needs special operational care. They are current package constraints, not a protocol comparison or a task backlog. + +- **One primary workspace** — additional directories remain unsupported. +- **Raster prompt images only** — PNG, JPEG, WebP, and GIF require a durable attachment store and an exact image-capable route. +- **MCP tools only** — MCP resources and prompts have no DSH consumer. +- **No transcript replay or interactive extensions** — session deletion, fork, `session/load`, modes, commands, plans, terminals, client filesystem operations, and elicitation remain outside this automation surface. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/acp/acp/README.zh.md b/packages/acp/acp/README.zh.md index e722dbf064..80391c8791 100644 --- a/packages/acp/acp/README.zh.md +++ b/packages/acp/acp/README.zh.md @@ -1,59 +1,145 @@ +--- +description: "面向程序化客户端与维护者的仅自动化 Agent Client Protocol 服务器,用于通过 JSON-RPC stdio 驱动 DeepSeek Harness agent。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-acp [English](README.md) | 中文 -通过 JSON-RPC stdio 提供的仅面向自动化的 [ACP(Agent Client Protocol)](https://agentclientprotocol.com) 服务器。程序化客户端可以创建新 harness agent(智能体)、发送文本/图片提示词、收集已提交的 assistant 文本/图片、按策略响应一次性权限请求并取消工作。仓库中的主要客户端是 [`dsh-subagent-acp`](../../subagent/subagent-acp/README.md)。 +## 概述 -此包是传输适配器,而非 UI 集成或能力 seam。它不公开编辑器导航、transcript(文本记录)回放、命令、模式、配置选择器、信息征集、推理(reasoning)、计划、标题或工具展示。交互式渲染与向用户提问属于 Web 宿主和客户端模块。 +`dsh-acp` 让受信程序可以通过标准 [Agent Client Protocol(ACP)](https://agentclientprotocol.com) 驱动持久 DeepSeek Harness agent:创建或恢复会话、列出可恢复会话、挂载标准 MCP 服务器、选择模型与推理强度、发送或取消工作、接收语义执行更新,并关闭一个会话而不影响其他会话。它是为自动化而生的——进程外 subagent、测试运行器与脚本化控制器——而不是 DSH 用户界面:它发送标准 ACP 消息、thought、通用工具生命周期、配置与上下文用量,绝不发送 DSH 私有呈现数据或方法。会话持久化支持跨进程重启的列出、恢复与关闭,而删除、fork、转录回放、附加目录与交互式 UI 界面仍不支持。仓库自带的 ACP 客户端是 `dsh-subagent-acp`,`pnpm dsh --profile acp` 会启动一个开箱即用的服务器。设置与用法在前;实现细节放在下方可折叠的开发者章节中。 -## 插件 +## 目录 -`apply(ctx, config)` 在 stdin/stdout 上打开 `AgentSideConnection` 并驱动 `ctx.agents`。Stdout 专用于协议帧。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -| 配置 | 默认值 | 含义 | +----- + + +## 使用本包 + +当脚本、测试运行器或另一个 harness 需要通过标准自动化协议端到端运行 agent 工作时,使用本包。常用路径是:启动服务器、创建或恢复会话、按需挂载 MCP 服务器并选择模型选项、发送提示词、消费语义更新,再关闭会话。 + +### 何时选择 + +当自动化应拥有交互时选择它:管理持久会话、工具、模型选择与权限的进程外 subagent、测试运行器或脚本化控制器。当人类需要 DSH 专用呈现卡片、计划、标题、todo、终端视图或 elicitation 时请避开;本服务器刻意只提供标准 ACP v1 界面。 + +### 最小配置 + +服务器创建的每个会话都使用此处配置的提供方与模型。两个字段都是可选的,以便由另一个 agent/request 监听器提供;可运行的演示组合会同时设置两者。Stdout 只承载协议流量,因此请让日志远离它。 + +```yaml +- name: '@deepseek-ai/dsh-acp' + config: + provider: deepseek-official + model: deepseek-v4-pro +``` + +| 字段 | 默认值 | 含义 | |---|---|---| -| `provider` | 无 | 每个已创建 agent 的初始提供方路由。 | -| `model` | 无 | 每个已创建 agent 的初始模型。 | +| `provider` | — | 每个会话 agent 的提供方路由 | +| `model` | — | 每个会话 agent 的模型 | +| `sessionListPageSize` | `100` | 单页 `session/list` 返回的最大摘要数量 | -两个字段都是可选的,以便由另一个 agent/request 监听器提供目标。可运行的 ACP 组合同时要求两者。 +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-acp)是每个受支持字段及其 JSDoc 的穷尽式真源。 -## 协议约定 +### 启动服务器 -| 方法 | 行为 | +`pnpm dsh --profile acp` 会启动随附的 stdio 服务器。`acp` profile 会挂载会话持久化,因此客户端可以列出、恢复和关闭持久会话。[`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.zh.md) 会启动同一 profile 来执行进程外委派。 + + +### 协议约定 + +一个连接可以同时运行多个会话,彼此独立。客户端发出的调用如下: + +| 调用 | 你会得到什么 | |---|---| -| `initialize` | 协商受支持的版本。只有挂载持久附件存储,且配置的确切提供方/模型解析后明确支持图片输入时,才公布图片提示词能力;音频与嵌入上下文保持 false。不公布会话、编辑器、终端、文件系统或 MCP 能力。 | -| `authenticate` | 空操作,因为服务器不公布身份验证方法。 | -| `session/new` | 以绝对路径作为主 `cwd` 创建新 agent;接受空的 `additionalDirectories` 和 `mcpServers`,拒绝非空值。 | -| `session/prompt` | 保留文本与受支持内联图片块的顺序,将资源链接渲染为带方括号的文本引用,并拒绝音频、嵌入资源、格式错误/空输入,或在未公布能力时提交图片。它会先校验完整图片批次并重新检查会话的最新确切路由,再保存任一成员;在用户事件前提交全部图片;每个会话只允许一个正在处理的请求,并等待准入,以及消息入队后的整个 Agent 空闲和有序输出交付全部停稳。正常完全停稳时报告 `end_turn`;显式 ACP 取消、资源释放,或准入被丢弃的提示词(无轮次槽位)时报告 `cancelled`。 | -| `session/cancel` | 标记并中止正在进行的准入,但不会取消或等待同一 Agent 上无关的既有工作;该提示词进入 Agent inbox 后,才会取消指定的 Agent 并等待自有区间停稳。不发布迟到的用户消息,提示词以 `cancelled` 结算。没有进行中的提示词时会取消自主工作;未知 id 为空操作。 | -| `session/update` | 为已提交 `assistant/message` 中的每个非空文本或图片块发出一个 `agent_message_chunk`,并保留顺序。图片在以内联 base64 交付前会重新读取并校验完整性。省略原始增量和非消息事件。 | -| `session/request_permission` | 为携带工具调用 id、由桥接层拥有的批准请求提供一次性允许/拒绝选项。客户端可以自动回答。 | +| `initialize` | 稳定 ACP v1,以及 `session/list`、`session/resume`、`session/close` 与 Streamable HTTP MCP 支持;图片提示词只在持久附件存储和配置的确切路由支持时公布。 | +| `authenticate` | 立即成功;服务器不需要身份验证。 | +| `session/new` | 全新持久 agent;其绝对工作区与 stdio 或 HTTP MCP 服务器会在发布前通过校验,并返回完整配置选项状态。 | +| `session/list` | 按确定的新到旧顺序分页返回已持久、可恢复的根会话;可选绝对 `cwd` 筛选会尽可能使用物理目录标识。 | +| `session/resume` | 恢复一个已持久且非活跃的会话;组合前校验其规范工作区,并恢复日志但不回放旧更新。 | +| `session/close` | 停稳式取消、更新 drain、后代释放、持久化 flush,并且只释放指定 Agent 作用域。 | +| `session/set_config_option` | 串行更新公布的 `model` 或 `reasoning_effort`,并返回完整结果状态。 | +| `session/prompt` | 有序文本、资源链接与受支持图片,每个会话一次一个提示词;Agent 空闲且有序更新交付后才结算。 | +| `session/cancel` / `$/cancel_request` | 提示词所拥有的取消路径;没有进行中的 ACP 提示词时取消自主工作,未知会话 id 则为空操作。 | +| `session/update` | 已提交 assistant 消息与 thought、通用工具生命周期、配置变化与上下文用量,按会话串行交付。 | +| `session/request_permission` | 带一次性允许/拒绝选项的权限提示;你的客户端可以自动回答。 | -一个连接可以拥有多个会话。桥接层以带品牌的会话 id 作为记录键,并在路由事件或权限请求前检查 agent 是否为同一对象。每个会话都有独立的提示词槽位、工作区、取消路径和资源释放器。 +会话配置从实时 LLM 服务目录提供不透明的提供方/模型选项,并在确切模型声明推理选项时提供 `reasoning_effort`。提示词会在异步图片准入前快照该选择,并在该轮的每个模型步骤中固定它;并发选项变更从下一轮开始生效。ACP 客户端是受信控制器:stdio MCP 条目授权其绝对命令与环境,HTTP 条目授权其绝对 HTTP(S) URL 与 header;初始连接或发现失败会回滚尚未发布的 Agent。不支持的界面会被省略或拒绝:`session/load`、删除、fork、附加目录、SSE 或 ACP 传输 MCP、mode、命令、计划、终端、客户端文件系统操作与 elicitation。 -已提交消息输出有意牺牲逐 token 输出的低延迟,以换取干净的自动化结果。未提交的提供方分片和重试尝试无法泄漏部分文本或图片;推理与工具活动仍保留在会话日志中,以便其他界面观测。由于附件读取是异步的,每个会话会串行交付内容;已提交图片缺失或损坏时,提示词响应会失败,而不会发出占位符。 +----- -## 生命周期 + +## 理解实现 -客户端断开与 Cordis 释放共用同一个记忆化清理流程。桥接层先拒绝新会话和提示词,取消并等待提示词准入、agent 活动和有序输出交付全部停稳,然后只 drain 此连接确切拥有的 Agent 之下的可继续后代,再并行释放这些 handle,并等待全部结果结算后才报告失败。其他共享该上下文的前端会保留其可继续森林和准入。因此,仅 ACP 的插件重载不会遗留 agent。 +
+实现细节——点击展开 -ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它表示提示词专属的轮次结果。操作区间从提示词进入 Agent inbox 开始,在准入、整个 Agent 空闲和有序输出交付全部停稳后结束;inbox 接收前无关 Agent 工作的失败不会归因给该提示词。已提交的 assistant 消息会在自有区间内流式输出,Agent 进入空闲状态前发生的 steering(中途引导)或注入工作也可能参与其中。结算优先级依次为显式取消、输出交付失败、区间内 Agent 失败、关联轮次结束。因 token 上限而结束时以 `end_turn` 结算;关联模型错误也只会在同一个完全停稳边界拒绝提示词。 +本节解释服务器如何实现上述行为,并指出实现它的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 -## 运行 +### 设计理念 -`pnpm --dir /path/to/deepseek-harness run demo:acp` 启动仓库的自动化服务器组合。父 harness 可以通过 [`@deepseek-ai/dsh-subagent-acp`](../../subagent/subagent-acp/README.md) spawn 它;其他 ACP 客户端只需上述核心方法。 +服务器是刻意采用标准公开协议的自动化传输。三项承诺塑造了它: +- **只发送标准语义更新。** 协议承载已提交消息与 thought、通用工具生命周期、配置与上下文用量;原始提供方增量、重试尝试、DSH 呈现数据与不受支持内容不会进入协议。 +- **诚实的能力与配置状态。** `initialize` 只公布已挂载支持,拓扑变化会发布完整配置选项,提示词则固定其准入时的确切路由。 +- **停稳后才结算。** 提示词与关闭操作只在其拥有的准入、Agent 活动、有序更新、后代、持久化与释放达到所需终态后才结算。 + +决策历史记录在 [ACP 作为仅面向自动化的协议笔记](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md) 与[多会话笔记](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md) 中。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`AgentSideConnection` 接线、按会话记录、准入与结算、清理 | +| [`src/content.ts`](src/content.ts) | 协议内容准入与投影:图片校验、路由重查、提示词重建、assistant 块转换 | +| [`src/codec.ts`](src/codec.ts) | 轮次结束到 ACP `stopReason` 的纯映射 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;本传输不拥有持久包内事件流) | + +### 准入与提示词结算 + +每个会话只允许一个正在处理的提示词。准入先校验整个提示词批次、快照所选路由、重新检查 Agent 是否为同一对象与图片能力、持久化图片附件,然后才把用户消息入队——赢得准入的取消绝不会入队迟到的轮次。入队后,会话模块把该快照与 inbox 消息关联到认领时刻,并在提示词变量与该轮的每个模型步骤中固定相同的提供方、模型与推理强度。按会话更新会串行交付;已提交图片会重新读取并验证完整性,因此图片缺失或损坏会让关联提示词失败,而不是发出占位符。结算优先级依次为显式取消、已提交输出失败、区间内 Agent 失败、关联轮次结束。 + +### 清理与连接归属 + +每个会话模块拥有其 Agent 句柄、MCP 挂载、未来与轮次固定的模型选择、提示词槽位、更新链和记忆化关闭操作。显式关闭、客户端断开与 Cordis 释放使用同一停稳式清理流程:停止新工作、取消提示词准入与 Agent 活动、drain 已提交更新、按子优先顺序释放可继续后代、flush 持久化并释放所拥有的 Agent 作用域。会话关闭后,持久状态仍可供列出与恢复;共享上下文的其他会话或前端不受影响。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从匹配的客户端逐步进入自动化约定背后的设计记录。 + +- [dsh-subagent-acp](../../subagent/subagent-acp/README.zh.md)——spawn 并驱动本服务器的进程外 ACP 客户端。 +- [ACP 作为仅面向自动化的协议](../../../.agents/notes/implemented/simplification/2026-07-23-acp-automation-only-protocol.zh.md)——自动化约定及其协议边界的决策记录。 +- [在单个连接上多路复用并发 ACP 会话](../../../.agents/notes/implemented/feature/2026-06-14-acp-multi-session.zh.md)——按会话隔离、归属与清理决策。 +- [扩展实操手册](../../../docs/cookbook/extension-cookbook.zh.md)——本包作为扩展作者的仅自动化完整示例。 + +----- + + ## 模型体验 -### 提示词文本与图片 +### 提示词内容 -#### 模型看到的内容 +#### 模型看到什么 -`session/prompt` 会在一条用户消息中保留文本/图片顺序;相邻文本会拼接,资源链接则表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。内联图片 base64 在批量准入后即被丢弃,因此持久消息只包含经过校验的附件引用。协议元数据、客户端能力、权限选择和会话 id 绝不进入模型请求。 +`session/prompt` 会在一条用户消息中保留文本与图片顺序:相邻文本会拼接,资源链接则表示为带方括号的 `[resource_link name=… uri=…]` 引用,模型可以使用自身工具打开它。内联图片 base64 在批量准入后即被丢弃,因此持久消息只包含经过校验的附件引用。协议元数据、客户端能力、权限选择与会话 id 绝不进入模型请求。 #### Token 影响 -提示词 token 与图片费用取决于数据,并保留在该会话的历史中直到上下文压缩(context compaction)。并发 ACP 会话保留独立上下文。 +提示词内容、工具调用/结果和持久图片引用会保留在该会话中直到 compaction。并发会话保留独立上下文。 #### KV Cache 影响 @@ -61,7 +147,7 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它 ### 权限决策 -#### 模型看到的内容 +#### 模型看到什么 不会直接看到任何内容。所属工具通过常规工具结果路径记录其结果:允许、拒绝、取消或不可用。 @@ -73,9 +159,24 @@ ACP 要求每个提示词响应都携带 `stopReason`,但桥接层不声称它 仅通过所属工具的结果追加。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **仅新会话**:不支持加载、列出、恢复、删除和 fork。 -- **仅光栅图片和一个 workspace**:图片提示词要求持久存储以及明确声明支持图片输入的确切路由;只接受 PNG、JPEG、WebP 和 GIF。音频、嵌入资源、非空附加目录和 MCP 服务器都会被拒绝;资源链接只会展平为文本引用,不会获取其内容。 -- **仅已提交答案**:实时进度、推理、工具活动、计划、标题和用量不会通过协议传输。 -- **由连接管理的生命周期**:一个连接会释放其所有会话;尚未实现单个会话关闭功能。 + + + +这些限制说明本包何时不合适,或何时需要特别的运维注意。它们是当前包约束,不是协议对比或任务积压。 + +- **仅一个主 workspace**——附加目录仍不支持。 +- **仅光栅提示词图片**——PNG、JPEG、WebP 与 GIF 要求持久附件存储及确切的图片能力路由。 +- **仅 MCP 工具**——MCP resource 与 prompt 没有 DSH 消费方。 +- **没有转录回放或交互式扩展**——会话删除、fork、`session/load`、mode、命令、计划、终端、客户端文件系统操作与 elicitation 仍不属于此自动化界面。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index b099fd90f3..1bd6af44c1 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -32,7 +32,7 @@ ], "license": "MIT", "dependencies": { - "@agentclientprotocol/sdk": "0.25.1", + "@agentclientprotocol/sdk": "1.4.0", "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { @@ -40,10 +40,18 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-token-meter": { + "optional": true + } + }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,7 +59,11 @@ "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/cordis": "workspace:^" diff --git a/packages/acp/acp/src/content.ts b/packages/acp/acp/src/content.ts index 66ac7ea3be..e31807b276 100644 --- a/packages/acp/acp/src/content.ts +++ b/packages/acp/acp/src/content.ts @@ -4,7 +4,7 @@ import type { ContentBlock as AcpContentBlock } from '@agentclientprotocol/sdk' import type { Context } from '@deepseek-ai/cordis' import { isImageAdmissionError } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, ImageMediaType, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' /** Raster formats shared by ACP image blocks and the core attachment vocabulary. */ @@ -60,10 +60,9 @@ function decodeImage(block: Extract): SaveIm } /** Resolve the exact current route and require explicit image input support. */ -async function assertImageRoute(ctx: Context, agent: Agent, signal: AbortSignal): Promise { - const routed = agent.session.requestHeader()?.config - const provider = routed?.provider ?? agent.options.provider - const model = routed?.model ?? agent.options.model +async function assertImageRoute(ctx: Context, route: ModelSelection | undefined, signal: AbortSignal): Promise { + const provider = route?.provider + const model = route?.model const llm = ctx.get('llm') if (provider === undefined || model === undefined || llm === undefined) { throw new AcpContentError('the current model route could not be resolved for image input', 'invalid') @@ -115,7 +114,7 @@ function resourceLinkText(block: Extract 0) { const attachments = ctx.get('attachments') if (attachments === undefined) throw new AcpContentError('no attachment store is mounted', 'invalid') - await assertImageRoute(ctx, agent, signal) + await assertImageRoute(ctx, route, signal) signal.throwIfAborted() try { refs = await attachments.saveImages(images) diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index 7be2a2bda6..fa9489f5ec 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -1,61 +1,64 @@ /** * Automation-only Agent Client Protocol server over JSON-RPC stdio. * - * The bridge exposes fresh harness sessions to trusted programmatic clients. It - * carries prompt text/images, committed assistant text/images, cancellation, - * and one-shot permission decisions; presentation and human-interaction - * features stay with the harness's UI modules. + * The bridge exposes persistent harness sessions to trusted programmatic + * clients. It carries standard configuration, MCP mounts, prompt content, + * committed semantic updates, cancellation, and one-shot permission decisions; + * presentation and human-interaction features stay with the harness's UI modules. * * @module @deepseek-ai/dsh-acp */ import type { Context } from '@deepseek-ai/cordis' +import { Buffer } from 'node:buffer' import { randomUUID } from 'node:crypto' -import { isAbsolute } from 'node:path' +import { realpath } from 'node:fs/promises' +import { isAbsolute, resolve } from 'node:path' import { Readable, Writable } from 'node:stream' import Schema from '@deepseek-ai/schemastery' -import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' +import { errorChain } from '@deepseek-ai/dsh-llm' import { - AgentSideConnection, + agent as createAcpAgentApp, + methods, ndJsonStream, PROTOCOL_VERSION, RequestError, - type Agent as AcpAgent, + type AgentContext, type AuthenticateRequest, type CancelNotification, + type CloseSessionRequest, + type CloseSessionResponse, type InitializeRequest, type InitializeResponse, + type ListSessionsRequest, + type ListSessionsResponse, type NewSessionRequest, type NewSessionResponse, type PromptRequest, type PromptResponse, + type RequestPermissionRequest, + type ResumeSessionRequest, + type ResumeSessionResponse, + type SetSessionConfigOptionRequest, + type SetSessionConfigOptionResponse, type SessionNotification, - type StopReason, type Stream, } from '@agentclientprotocol/sdk' -import type { Agent } from '@deepseek-ai/dsh-agent' -import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-persistence' // Side-effect type import: declaration-merges the approval waterfall answered below. import type {} from '@deepseek-ai/dsh-user-approval' -import { AcpContentError, admitAcpPrompt, assistantBlockToAcp, supportsAcpImagePrompts } from './content.ts' -import { turnEndToStopReason } from './codec.ts' +import { supportsAcpImagePrompts } from './content.ts' +import { AcpMcpConfigError } from './mcp.ts' +import { AcpModelConfigError } from './model-control.ts' +import { AcpSession } from './session.ts' + +const DEFAULT_SESSION_LIST_PAGE_SIZE = 100 export const name = 'acp' -/** The bridge creates and owns agents; every other concern is carried by the agent composition. */ -export const inject = ['agents'] - -/** - * The single continuable-subagent teardown the bridge needs. Declared - * structurally so this package does not depend on the subagent seam for one - * shutdown hook; an absent service means nothing continuable was materialized. - */ -interface ContinuableDrain { - /** - * Close admission below exact host-owned parents, then dispose only their - * continuable descendants child-first. - */ - drainContinuableDescendants(parents: readonly Agent[]): Promise -} +/** Core services required by the standard automation controls. */ +export const inject = ['agents', 'llm', 'sessionPersistence', 'sessions'] /** Preserve invalid-parameter detail in the SDK wire error message. */ function invalidParams(detail: string): RequestError { @@ -73,6 +76,8 @@ export interface AcpConfig { provider?: string /** Model name for created agents. */ model?: string + /** Maximum summaries returned by one session/list page. */ + sessionListPageSize?: number /** Runtime-only transport override; production uses stdio. */ stream?: Stream } @@ -80,39 +85,9 @@ export interface AcpConfig { export const Config: Schema = Schema.object({ provider: Schema.string(), model: Schema.string(), + sessionListPageSize: Schema.natural().min(1).default(DEFAULT_SESSION_LIST_PAGE_SIZE), }) -/** Per-session protocol state. */ -interface SessionRecord { - agent: Agent - /** Exact owned-agent disposer; resolves after registry, loop, and session teardown. */ - dispose: () => Promise - /** Ordered assistant-output delivery; every task contains its own failure. */ - outputTail: Promise - /** In-flight admission/turn/output lifecycle for exact settlement. */ - inflight: { - resolve: (reason: StopReason) => void - reject: (error: Error) => void - /** Set only after rich-content admission succeeds and the message is built. */ - messageId: string | undefined - /** Whether this prompt has entered the Agent's durable inbox interval. */ - messageQueued: boolean - turn: number | undefined - /** The correlated turn's ending, set at turn/end and settled at whole-agent idle. */ - endReason: TurnEndReason | undefined - /** Admission quiescence gate, including any attachment write already in progress. */ - admissionDone: Promise - finishAdmission: () => void - admissionController: AbortController - cancelRequested: boolean - settlementStarted: boolean - /** Conversion failure for committed output owned by this prompt's turn. */ - outputError: Error | undefined - /** Interval-wide failure outside the correlated turn. */ - agentError: Error | undefined - } | undefined -} - /** * Mount the automation-only ACP server. * @param ctx - Cordis context carrying the agent factory and session events. @@ -121,24 +96,25 @@ interface SessionRecord { export function apply(ctx: Context, config: AcpConfig): void { // ACP handlers execute outside this plugin's injection scope, so capture the // injected service during apply rather than reading it lazily in a callback. - const agents = ctx.agents + const persistence = ctx.sessionPersistence const logger = ctx.logger - const sessions = new Map() + const sessionListPageSize = resolveSessionListPageSize(config.sessionListPageSize) + const sessions = new Map() + const activating = new Set() let closed = false - let conn: AgentSideConnection let imagePromptEnabled = false /** Return the bridge-owned record for an agent, rejecting same-id impostors. */ - const ownedRecord = (agent: Agent): SessionRecord | undefined => { + const ownedRecord = (agent: Parameters[0]): AcpSession | undefined => { const record = sessions.get(agent.session.id) - return record?.agent === agent ? record : undefined + return record?.owns(agent) === true ? record : undefined } const assertOpen = (): void => { if (closed) throw internalError('the ACP bridge has been disposed') } - const requireSession = (sessionId: SessionId): SessionRecord => { + const requireSession = (sessionId: SessionId): AcpSession => { const record = sessions.get(sessionId) if (record === undefined) throw invalidParams(`unknown session: ${sessionId}`) return record @@ -147,7 +123,7 @@ export function apply(ctx: Context, config: AcpConfig): void { /** Send one ordered protocol update while containing transport-only failure. */ const notify = async (notification: SessionNotification): Promise => { try { - await conn.sessionUpdate(notification) + await conn.notify(methods.client.session.update, notification) /* v8 ignore start -- the ACP SDK contains notification-handler failures; only a transport write failure reaches this guard. */ } catch (error: unknown) { logger.warn(`acp: session/update failed: ${String(error)}`) @@ -155,114 +131,21 @@ export function apply(ctx: Context, config: AcpConfig): void { /* v8 ignore stop */ } - const rejectFromError = ( - inflight: NonNullable, - reason: Extract, - ): void => { - inflight.reject(internalError(`turn failed: ${reason.error.message}`)) - } - - /** - * Settle one exact prompt only after admission, agent activity, and ordered - * assistant delivery have all reached quiescence. - */ - const settleAfterQuiescence = ( - record: SessionRecord, - inflight: NonNullable, - ): void => { - if (inflight.settlementStarted) return - inflight.settlementStarted = true - void (async () => { - await inflight.admissionDone - if (inflight.messageQueued) { - await record.agent.whenIdle() - // session/event enqueues synchronously before the agent becomes idle; - // reading the live tail here includes every committed output task. - await record.outputTail - } - /* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */ - if (record.inflight !== inflight) return - record.inflight = undefined - if (inflight.cancelRequested) { - inflight.resolve('cancelled') - return - } - if (inflight.outputError !== undefined) { - inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`)) - return - } - if (inflight.agentError !== undefined) { - inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`)) - return - } - const end = inflight.endReason - if (end === undefined) { - inflight.resolve('cancelled') - } else if (end.kind === 'error') { - rejectFromError(inflight, end) - } else { - // Token-limit and other non-terminal endings are not prompt-level stop - // reasons; ordinary quiescence reports end_turn. - inflight.resolve(end.kind === 'max-tokens' ? 'end_turn' : turnEndToStopReason(end)) - } - })() - /* v8 ignore start -- admissionDone only resolves, and the queued path's idle/output gates contain their own failures. */ - .catch((error: unknown) => { - if (record.inflight !== inflight) return - record.inflight = undefined - inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`)) - }) - /* v8 ignore stop */ - } - - // Emit only committed assistant text/images. Raw chunks, reasoning, tools, - // plans, titles, and retry markers are presentation or trace data and stay - // off the automation wire. One per-session chain preserves block/message - // order across asynchronous attachment reads. - ctx.on('session/event', (session, event: SessionEvent) => { + ctx.on('session/event', (session, event) => { const record = sessions.get(session.header.id) - if (record === undefined || record.agent.session !== session) return - try { - if (event.type === 'assistant/message') { - const inflight = record.inflight?.turn === event.data.turn ? record.inflight : undefined - const previous = record.outputTail - const delivery = previous.then(async () => { - for (const block of event.data.message.content) { - const content = await assistantBlockToAcp(ctx, block) - if (content === undefined) continue - await notify({ - sessionId: record.agent.session.id, - update: { sessionUpdate: 'agent_message_chunk', content }, - }) - } - }) - record.outputTail = delivery.catch((error: unknown) => { - // assistantBlockToAcp owns conversion failures and always throws Error. - const failure = error as Error - if (inflight !== undefined) inflight.outputError ??= failure - logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`) - }) - } - } finally { - const inflight = record.inflight - if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { - inflight.endReason = event.data.reason - } - } + if (record?.ownsSession(session) === true) record.onSessionEvent(session, event) }) ctx.on('agent/inbox/claimed', ({ agent, message, turn }) => { - const record = ownedRecord(agent) - const inflight = record?.inflight - if (inflight !== undefined && inflight.messageId === message.id) inflight.turn = turn + ownedRecord(agent)?.onInboxClaimed(message, turn) }) ctx.on('agent/error', ({ agent, turn, error }) => { - const record = ownedRecord(agent) - const inflight = record?.inflight - if (record === undefined || inflight === undefined || !inflight.messageQueued || inflight.turn === turn) return - inflight.agentError = new Error(errorChain(error)) - settleAfterQuiescence(record, inflight) + ownedRecord(agent)?.onAgentError(turn, error) + }) + + ctx.on('llm/adapters-updated', () => { + for (const record of sessions.values()) record.topologyChanged() }) // Permission requests are a machine policy channel for ACP clients such as @@ -271,173 +154,218 @@ export function apply(ctx: Context, config: AcpConfig): void { ctx.on('approval/request', (request, next) => { const record = ownedRecord(request.agent) if (record === undefined || request.callId === undefined) return next() - return conn.requestPermission({ - sessionId: record.agent.session.id, - toolCall: { toolCallId: request.callId }, - options: [ - { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, - { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, - ], + const callId = request.callId + return record.drainUpdates().then(() => { + const params: RequestPermissionRequest = { + sessionId: record.agent.session.id, + toolCall: { toolCallId: callId }, + options: [ + { optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, + { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }, + ], + } + return conn.request(methods.client.session.requestPermission, params) }).then(({ outcome }) => { if (outcome.outcome === 'cancelled') return 'cancelled' return outcome.optionId === 'allow-once' ? 'allowed-once' : 'rejected' }) }) - const makeAgent = (connection: AgentSideConnection): AcpAgent => { - conn = connection - return { - async initialize(_params: InitializeRequest): Promise { - // Single-version agent: the spec's "same version if supported, else - // the latest supported" both resolve to this server's one version. - imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model) - return { - protocolVersion: PROTOCOL_VERSION, - agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, - agentCapabilities: { - promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false }, - }, - authMethods: [], - } - }, + const implementation = { + async initialize(_params: InitializeRequest): Promise { + // Single-version agent: the spec's "same version if supported, else + // the latest supported" both resolve to this server's one version. + imagePromptEnabled = await supportsAcpImagePrompts(ctx, config.provider, config.model) + return { + protocolVersion: PROTOCOL_VERSION, + agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, + agentCapabilities: { + mcpCapabilities: { http: true }, + promptCapabilities: { image: imagePromptEnabled, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, + }, + authMethods: [], + } + }, - authenticate(_params: AuthenticateRequest): Promise { - return Promise.resolve() - }, + authenticate(_params: AuthenticateRequest): Promise { + return Promise.resolve() + }, - async newSession(params: NewSessionRequest): Promise { - assertOpen() - validateSessionParams(params) - const sessionId = SessionId(randomUUID()) - // No preset composition: the ACP bundle keeps the model-facing rows in - // the host plane, so this agent reads them from the global layer. A - // deployment that configures a roster has to join one here first - // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). - const handle = await agents.create({ + async newSession(params: NewSessionRequest, signal: AbortSignal): Promise { + assertOpen() + validateWorkspaceParams(params) + const sessionId = SessionId(randomUUID()) + // No preset composition: the ACP bundle keeps the model-facing rows in + // the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). + let record: AcpSession + try { + record = await AcpSession.create(ctx, { sessionId, - meta: { cwd: params.cwd }, + cwd: params.cwd, + mcpServers: params.mcpServers, agentOptions: agentOptions(config), + fallbackSelection: initialSelection(config), + signal, + notify, }) - /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ - if (closed) { - await handle.dispose() - throw internalError('connection closed during session/new') - } - sessions.set(sessionId, { - agent: handle.agent, - dispose: () => handle.dispose(), - outputTail: Promise.resolve(), - inflight: undefined, - }) - return { sessionId } - }, - - async prompt(params: PromptRequest): Promise { + } catch (error: unknown) { + if (error instanceof AcpMcpConfigError) throw invalidParams(error.message) + throw error + } + /* v8 ignore next 4 -- a real stdio close can race an in-flight create. */ + if (closed) { + await record.close('connection closed during session/new') + throw internalError('connection closed during session/new') + } + sessions.set(sessionId, record) + try { + const configOptions = await record.configOptions(signal) assertOpen() - const record = requireSession(SessionId(params.sessionId)) - if (record.inflight !== undefined) { - throw invalidParams('a prompt is already in flight for this session') - } - const completion = Promise.withResolvers() - const admission = Promise.withResolvers() - const admissionController = new AbortController() - const inflight: NonNullable = { - resolve: completion.resolve, - reject: completion.reject, - messageId: undefined, - messageQueued: false, - turn: undefined, - endReason: undefined, - admissionDone: admission.promise, - finishAdmission: admission.resolve, - admissionController, - cancelRequested: false, - settlementStarted: false, - outputError: undefined, - agentError: undefined, - } - // Reserve the one-prompt slot before the first asynchronous route or - // attachment operation so concurrent prompts and cancellation observe - // admission as genuinely in flight. - record.inflight = inflight + await persistence.ensureMaterialized(record.agent.session) + assertOpen() + return { sessionId, configOptions } + } catch (error: unknown) { + sessions.delete(sessionId) + await record.close('session/new activation failed') + throw error + } + }, - let admissionFailed = false - let admissionFailure: unknown + async resumeSession(params: ResumeSessionRequest, signal: AbortSignal): Promise { + assertOpen() + validateWorkspaceParams(params) + const sessionId = SessionId(params.sessionId) + if (sessions.has(sessionId) || activating.has(sessionId) || ctx.sessions.get(sessionId) !== undefined) { + throw invalidParams(`session is already active: ${sessionId}`) + } + activating.add(sessionId) + return (async (): Promise => { + const persisted = (await persistence.list(signal)).find(header => header.id === sessionId) + if (persisted === undefined || persisted.origin === 'subagent' || persisted.parentSession !== undefined) { + throw invalidParams(`session is not resumable: ${sessionId}`) + } + if (!await sameDirectory(persisted.cwd, params.cwd)) { + throw invalidParams(`session cwd does not match: ${params.cwd}`) + } + let record: AcpSession try { - // Do not persist rich content for a retired destination. Re-check - // after admission too because an agent-loop reload may race storage. - if (ctx.agents.get(record.agent.id) !== record.agent) { - throw internalError('prompt was not queued: the agent was disposed outside the bridge') - } - const content = await admitAcpPrompt( - ctx, - record.agent, - params.prompt, - imagePromptEnabled, - admissionController.signal, - ) - // No await may separate this final abort check from followup: a - // cancellation that wins admission must never enqueue a late turn. - admissionController.signal.throwIfAborted() - if (ctx.agents.get(record.agent.id) !== record.agent) { - throw internalError('prompt was not queued: the agent was disposed outside the bridge') - } - const message = createUserMessage({ content, source: { kind: 'user' } }) - inflight.messageId = message.id - inflight.messageQueued = true - try { - record.agent.followup(message) - } catch (error: unknown) { - // The typed same-process seam may fail synchronously before durable - // inbox receipt; restore the pre-operation boundary for mapping. - inflight.messageQueued = false - throw error - } + record = await AcpSession.resume(ctx, { + sessionId, + cwd: params.cwd, + mcpServers: params.mcpServers ?? [], + agentOptions: agentOptions(config), + fallbackSelection: initialSelection(config), + signal, + notify, + }) } catch (error: unknown) { - admissionFailed = true - admissionFailure = error - } finally { - inflight.finishAdmission() + if (error instanceof AcpMcpConfigError) throw invalidParams(error.message) + throw error } + /* v8 ignore start -- the persisted header was checked before resume; the factory restores that exact header. */ + if (!await sameDirectory(record.agent.session.header.cwd, params.cwd)) { + await record.close('session/resume cwd mismatch') + throw invalidParams(`session cwd does not match: ${params.cwd}`) + } + /* v8 ignore stop */ + /* v8 ignore next 4 -- a real stdio close can race an in-flight resume. */ + if (closed) { + await record.close('connection closed during session/resume') + throw internalError('connection closed during session/resume') + } + sessions.set(sessionId, record) + try { + return { configOptions: await record.configOptions(signal) } + } catch (error: unknown) { + sessions.delete(sessionId) + await record.close('session/resume option discovery failed') + throw error + } + })().finally(() => { activating.delete(sessionId) }) + }, - if (inflight.cancelRequested) { - settleAfterQuiescence(record, inflight) - return { stopReason: await completion.promise } - } - if (admissionFailed) { - record.inflight = undefined - if (admissionFailure instanceof AcpContentError) { - throw admissionFailure.kind === 'invalid' - ? invalidParams(admissionFailure.message) - : internalError(admissionFailure.message) - } - if (admissionFailure instanceof RequestError) throw admissionFailure - // The admission codec and same-process agent seam throw Error values. - const detail = (admissionFailure as Error).message - throw internalError(`prompt was not queued: ${detail}`) + async listSessions(params: ListSessionsRequest, signal: AbortSignal): Promise { + assertOpen() + if (params.cwd !== undefined && params.cwd !== null && !isAbsolute(params.cwd)) { + throw invalidParams(`cwd must be an absolute path: ${params.cwd}`) + } + let cursor: SessionListCursor | undefined + try { + cursor = decodeSessionListCursor(params.cursor) + } catch (error: unknown) { + throw invalidParams((error as Error).message) + } + const listed = await persistence.list(signal) + const filtered = await Promise.all(listed.map(async (header) => { + if ( + sessions.has(header.id) + || activating.has(header.id) + || ctx.sessions.get(header.id) !== undefined + || header.origin === 'subagent' + || header.parentSession !== undefined + || header.cwd === undefined + || !isAbsolute(header.cwd) + ) return undefined + if (params.cwd !== undefined && params.cwd !== null && !await sameDirectory(header.cwd, params.cwd)) { + return undefined } + return { sessionId: header.id, cwd: header.cwd, createdAt: header.createdAt } + })) + const entries = filtered + .filter((entry): entry is NonNullable => entry !== undefined) + .sort((left, right) => right.createdAt - left.createdAt || compareSessionIds(left.sessionId, right.sessionId)) + const remaining = cursor === undefined + ? entries + : entries.filter(entry => isAfterSessionListCursor(entry, cursor)) + const page = remaining.slice(0, sessionListPageSize) + const next = remaining.length > page.length ? page.at(-1) : undefined + return { + sessions: page.map(({ sessionId, cwd }) => ({ sessionId, cwd })), + ...next === undefined ? {} : { nextCursor: encodeSessionListCursor(next) }, + } + }, - settleAfterQuiescence(record, inflight) - const stopReason = await completion.promise - return { stopReason } - }, + async setSessionConfigOption( + params: SetSessionConfigOptionRequest, + signal: AbortSignal, + ): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + try { + return { configOptions: await record.setConfig(params.configId, params.value, signal) } + } catch (error: unknown) { + if (error instanceof AcpModelConfigError) throw invalidParams(error.message) + throw error + } + }, - cancel(params: CancelNotification): Promise { - const record = sessions.get(SessionId(params.sessionId)) - if (record === undefined) return Promise.resolve() - const inflight = record.inflight - if (inflight !== undefined) { - inflight.cancelRequested = true - inflight.admissionController.abort(new Error('ACP prompt cancelled')) - settleAfterQuiescence(record, inflight) - } - // Admission is not Agent work. Preserve unrelated producers until this - // prompt has entered the durable inbox; without a prompt, cancellation - // continues to target autonomous work on the addressed Agent. - if (inflight === undefined || inflight.messageQueued) record.agent.cancel({ kind: 'user' }) - return Promise.resolve() - }, - } + async closeSession(params: CloseSessionRequest): Promise { + assertOpen() + const sessionId = SessionId(params.sessionId) + const record = requireSession(sessionId) + try { + await record.close('ACP session closed') + } catch (error: unknown) { + throw internalError(`session close failed: ${errorChain(error)}`) + } finally { + if (sessions.get(sessionId) === record) sessions.delete(sessionId) + } + return {} + }, + + async prompt(params: PromptRequest, requestSignal: AbortSignal): Promise { + assertOpen() + const record = requireSession(SessionId(params.sessionId)) + return record.prompt(params, imagePromptEnabled, requestSignal) + }, + + cancel(params: CancelNotification): Promise { + sessions.get(SessionId(params.sessionId))?.cancel() + return Promise.resolve() + }, } /* v8 ignore next 4 -- production stdio wiring; tests inject config.stream. */ @@ -445,52 +373,35 @@ export function apply(ctx: Context, config: AcpConfig): void { Writable.toWeb(process.stdout) as WritableStream, Readable.toWeb(process.stdin) as ReadableStream, ) - conn = new AgentSideConnection(makeAgent, stream) + const app = createAcpAgentApp({ name: 'deepseek-harness-acp' }) + .onRequest(methods.agent.initialize, ({ params }) => implementation.initialize(params)) + .onRequest(methods.agent.authenticate, async ({ params }) => { + await implementation.authenticate(params) + return {} + }) + .onRequest(methods.agent.session.new, ({ params, signal }) => implementation.newSession(params, signal)) + .onRequest(methods.agent.session.list, ({ params, signal }) => implementation.listSessions(params, signal)) + .onRequest(methods.agent.session.resume, ({ params, signal }) => implementation.resumeSession(params, signal)) + .onRequest(methods.agent.session.close, ({ params }) => implementation.closeSession(params)) + .onRequest(methods.agent.session.setConfigOption, ({ params, signal }) => implementation.setSessionConfigOption(params, signal)) + .onRequest(methods.agent.session.prompt, ({ params, signal }) => implementation.prompt(params, signal)) + .onNotification(methods.agent.session.cancel, ({ params }) => implementation.cancel(params)) + const connection = app.connect(stream) + const conn: AgentContext = connection.client let quiescing: Promise | undefined const quiesce = (): Promise => { if (quiescing !== undefined) return quiescing closed = true const records = [...sessions.values()] - sessions.clear() - // Stop the bridge's own work before any await: a descendant drain can block - // on persistence or scoped cleanup, and the top-level agents must not keep - // running model and tool calls for its whole duration. - for (const record of records) { - const inflight = record.inflight - if (inflight !== undefined) { - inflight.cancelRequested = true - inflight.admissionController.abort(new Error('ACP bridge disposed')) - settleAfterQuiescence(record, inflight) - } - record.agent.cancel({ kind: 'user' }) - } + // AcpSession.close cancels synchronously before its first await, so every owned + // prompt stops before any descendant or persistence drain can block. quiescing = (async () => { - // Preserve the same prompt boundary during connection teardown: a rich - // admission already writing must stop before its slot settles, and every - // committed output conversion must drain while attachment services remain - // available. session/event enqueues output synchronously before idle. - await Promise.all(records.map(async (record) => { - await record.inflight?.admissionDone - await record.agent.whenIdle() - await record.outputTail - })) - // Continuable subagents outlive the turn that started them, and their - // Activations own descendant teardown. Drain only these sessions' forests - // child-first BEFORE disposing the top-level agents, so no descendant is - // left holding a runtime its owner already released and another frontend - // sharing this Context remains live. - // Read the one teardown method structurally: the bridge needs no other - // part of the subagent seam, so it does not depend on that package. - const subagents = ctx.get('subagents') as ContinuableDrain | undefined - if (subagents !== undefined) { - try { - await subagents.drainContinuableDescendants(records.map(record => record.agent)) - } catch (error: unknown) { - logger.warn(`acp: continuable subagent teardown failed: ${String(error)}`) - } + const disposals = await Promise.allSettled(records.map(record => record.close('ACP bridge disposed'))) + for (const record of records) { + /* v8 ignore next -- closed blocks concurrent handlers; each captured record remains mapped until this loop. */ + if (sessions.get(record.agent.session.id) === record) sessions.delete(record.agent.session.id) } - const disposals = await Promise.allSettled(records.map(record => record.dispose())) const failures: unknown[] = [] for (const result of disposals) { if (result.status === 'rejected') failures.push(result.reason as unknown) @@ -510,7 +421,7 @@ export function apply(ctx: Context, config: AcpConfig): void { } /* v8 ignore start -- production transport rejection and teardown failure. */ - void conn.closed + void connection.closed .catch((error: unknown) => { logger.warn(`acp: connection closed with an error: ${String(error)}`) }) @@ -535,11 +446,89 @@ function agentOptions(config: AcpConfig): { provider?: string; model?: string } } } -/** Reject session features outside the automation contract. */ -function validateSessionParams(params: NewSessionRequest): void { +/** Initial session selection when both deployment fields are present. */ +function initialSelection(config: AcpConfig): ModelSelection | undefined { + return config.provider === undefined || config.model === undefined + ? undefined + : { provider: config.provider, model: config.model } +} + +interface SessionListCursor { + createdAt: number + sessionId: string +} + +/** Resolve and validate the deployment-owned session page limit. */ +function resolveSessionListPageSize(value: number | undefined): number { + const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE + /* v8 ignore start -- Cordis applies the positive-integer Config schema; this protects direct apply callers. */ + if (!Number.isSafeInteger(resolved) || resolved < 1) { + throw new Error('acp: sessionListPageSize must be a positive safe integer') + } + /* v8 ignore stop */ + return resolved +} + +/** Decode an opaque keyset cursor without assigning meaning to client metadata. */ +function decodeSessionListCursor(value: string | null | undefined): SessionListCursor | undefined { + if (value === undefined || value === null) return undefined + if (!/^[A-Za-z0-9_-]+$/.test(value)) throw new Error('session/list cursor is invalid') + try { + const decoded = JSON.parse(Buffer.from(value, 'base64url').toString('utf8')) as unknown + const createdAt: unknown = Array.isArray(decoded) ? decoded[0] : undefined + const sessionId: unknown = Array.isArray(decoded) ? decoded[1] : undefined + if ( + !Array.isArray(decoded) + || decoded.length !== 2 + || typeof createdAt !== 'number' + || !Number.isSafeInteger(createdAt) + || createdAt < 0 + || typeof sessionId !== 'string' + || sessionId.length === 0 + ) throw new Error('invalid cursor fields') + const canonical = Buffer.from(JSON.stringify(decoded), 'utf8').toString('base64url') + if (canonical !== value) throw new Error('non-canonical cursor') + return { createdAt, sessionId } + } catch (_invalidCursor) { + throw new Error('session/list cursor is invalid') + } +} + +/** Encode the last returned ordering key as an opaque continuation token. */ +function encodeSessionListCursor(entry: SessionListCursor): string { + return Buffer.from(JSON.stringify([entry.createdAt, entry.sessionId]), 'utf8').toString('base64url') +} + +/** Test whether an entry follows the cursor in newest-first list order. */ +function isAfterSessionListCursor(entry: SessionListCursor, cursor: SessionListCursor): boolean { + return entry.createdAt < cursor.createdAt + || (entry.createdAt === cursor.createdAt && compareSessionIds(entry.sessionId, cursor.sessionId) > 0) +} + +/** Compare opaque session ids by stable UTF-8 bytes, independent of process locale. */ +function compareSessionIds(left: string, right: string): number { + return Buffer.compare(Buffer.from(left), Buffer.from(right)) +} + +/** Reject workspace features outside the automation contract. */ +function validateWorkspaceParams(params: { cwd: string; additionalDirectories?: string[] | null }): void { if (!isAbsolute(params.cwd)) throw invalidParams(`cwd must be an absolute path: ${params.cwd}`) - if (params.additionalDirectories !== undefined && params.additionalDirectories.length > 0) { + if ( + params.additionalDirectories !== undefined + && params.additionalDirectories !== null + && params.additionalDirectories.length > 0 + ) { throw invalidParams('additionalDirectories is not supported') } - if (params.mcpServers.length > 0) throw invalidParams('mcpServers is not supported') +} + +/** Compare existing directories by physical identity and missing paths lexically. */ +async function sameDirectory(left: string | undefined, right: string): Promise { + if (left === undefined) return false + try { + const [realLeft, realRight] = await Promise.all([realpath(left), realpath(right)]) + return realLeft === realRight + } catch (_unresolvablePath) { + return resolve(left) === resolve(right) + } } diff --git a/packages/acp/acp/src/mcp.ts b/packages/acp/acp/src/mcp.ts new file mode 100644 index 0000000000..527b064bba --- /dev/null +++ b/packages/acp/acp/src/mcp.ts @@ -0,0 +1,143 @@ +/** Standard ACP MCP-server declarations translated into Agent-scoped DSH MCP clients. */ + +import type { Context } from '@deepseek-ai/cordis' +import { createHash } from 'node:crypto' +import { validateHeaderName, validateHeaderValue } from 'node:http' +import { isAbsolute } from 'node:path' +import type { McpServer } from '@agentclientprotocol/sdk' +import * as McpClient from '@deepseek-ai/dsh-mcp-client' + +const VALID_SERVER_NAME = /^[A-Za-z0-9_-]{1,32}$/ + +/** Caller-correctable MCP declaration failure. */ +export class AcpMcpConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'AcpMcpConfigError' + } +} + +/** + * Validate and mount one session's complete standard MCP server list before Agent publication. + * @param agentCtx - unpublished Agent scope that owns the MCP clients and tools. + * @param servers - stable ACP stdio or HTTP server declarations. + * @param sessionCwd - canonical primary workspace used by stdio servers. + */ +export async function mountAcpMcpServers( + agentCtx: Context, + servers: readonly McpServer[], + sessionCwd: string, +): Promise { + const configs = resolveMcpConfigs(servers, sessionCwd) + for (const config of configs) await agentCtx.plugin(McpClient, config) +} + +/** Convert the stable stdio/HTTP ACP transports and reject every other transport. */ +function resolveMcpConfigs(servers: readonly McpServer[], sessionCwd: string): McpClient.Config[] { + const names = new Set() + return servers.map((server, index) => { + const serverName = normalizeServerName(server.name) + if (names.has(serverName)) { + throw new AcpMcpConfigError(`mcpServers contains duplicate normalized name: ${serverName}`) + } + names.add(serverName) + if (!('type' in server)) { + if (!isAbsolute(server.command)) { + throw new AcpMcpConfigError(`mcpServers[${index}].command must be an absolute path`) + } + const env = entriesToRecord(server.env, `mcpServers[${index}].env`, 'environment') + const config = validateClientConfig(index, () => McpClient.Config({ + transport: 'stdio', + serverName, + command: server.command, + args: server.args, + env, + cwd: sessionCwd, + failOnStartupError: true, + })) + return { ...config, env } + } + if (server.type === 'http') { + assertHttpUrl(server.url, `mcpServers[${index}].url`) + const headers = entriesToRecord(server.headers, `mcpServers[${index}].headers`, 'header') + const config = validateClientConfig(index, () => McpClient.Config({ + transport: 'streamable-http', + serverName, + url: server.url, + headers, + failOnStartupError: true, + })) + return { ...config, headers } + } + throw new AcpMcpConfigError(`mcpServers[${index}] transport ${server.type} is not supported`) + }) +} + +/** Convert ordered ACP name/value entries without silently accepting duplicate keys. */ +function entriesToRecord( + entries: readonly { name: string; value: string }[], + field: string, + kind: 'environment' | 'header', +): Record { + // Valid environment and header names include "__proto__"; a null prototype + // keeps that entry as data instead of invoking Object.prototype's setter. + const result = Object.create(null) as Record + const names = new Set() + for (const entry of entries) { + if (kind === 'header') { + try { + validateHeaderName(entry.name) + validateHeaderValue(entry.name, entry.value) + } catch (_invalidHeader) { + throw new AcpMcpConfigError(`${field} contains an invalid header entry`) + } + } else if ( + entry.name.length === 0 + || entry.name.includes('=') + || entry.name.includes('\0') + || entry.value.includes('\0') + ) { + throw new AcpMcpConfigError(`${field} contains an invalid environment entry`) + } + const identity = kind === 'header' ? entry.name.toLowerCase() : entry.name + if (names.has(identity)) throw new AcpMcpConfigError(`${field} contains duplicate name: ${entry.name}`) + names.add(identity) + result[entry.name] = entry.value + } + return result +} + +/** Produce a stable DSH tool namespace from ACP's human-readable server name. */ +function normalizeServerName(name: string): string { + if (name.trim().length === 0 || /[\u0000-\u001f\u007f]/.test(name)) { + throw new AcpMcpConfigError('mcpServers contains an invalid server name') + } + if (VALID_SERVER_NAME.test(name)) return name + const slug = name.normalize('NFKD') + .replace(/[^A-Za-z0-9_-]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 20) || 'server' + const digest = createHash('sha256').update(name).digest('hex').slice(0, 8) + return `${slug}_${digest}`.slice(0, 32) +} + +/** Require the stable Streamable HTTP transport URL schemes. */ +function assertHttpUrl(value: string, field: string): void { + try { + const url = new URL(value) + if (url.protocol !== 'http:' && url.protocol !== 'https:') throw new Error('unsupported protocol') + } catch (_invalidUrl) { + throw new AcpMcpConfigError(`${field} must be an absolute HTTP(S) URL`) + } +} + +/** Map the existing MCP provider's schema error into ACP invalid params. */ +function validateClientConfig(index: number, parse: () => McpClient.Config): McpClient.Config { + try { + return parse() + } catch (error: unknown) { + /* v8 ignore next -- Schemastery validation rejects with Error instances. */ + const detail = error instanceof Error ? error.message : String(error) + throw new AcpMcpConfigError(`mcpServers[${index}] is invalid: ${detail}`) + } +} diff --git a/packages/acp/acp/src/model-control.ts b/packages/acp/acp/src/model-control.ts new file mode 100644 index 0000000000..9138bcdcd7 --- /dev/null +++ b/packages/acp/acp/src/model-control.ts @@ -0,0 +1,237 @@ +/** Standard ACP session configuration over one Agent's model selection. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionConfigOption, SessionConfigValueId } from '@agentclientprotocol/sdk' +import { installModelSelection, type ModelSelection, type ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import { ReasoningEffortId, type LlmCallConfig, type LlmRuntime } from '@deepseek-ai/dsh-llm' + +const MODEL_CONFIG_ID = 'model' +const REASONING_CONFIG_ID = 'reasoning_effort' +// DSH reasoning effort ids are non-empty, so the empty opaque ACP value is a disjoint provider-default choice. +const PROVIDER_DEFAULT_REASONING_VALUE = '' + +interface ModelChoice { + selection: ModelSelection + value: SessionConfigValueId +} + +interface ConfigState { + choices: Map + options: SessionConfigOption[] +} + +/** Caller-correctable session configuration failure. */ +export class AcpModelConfigError extends Error { + constructor(message: string) { + super(message) + this.name = 'AcpModelConfigError' + } +} + +/** Project and mutate one Agent's provider/model/reasoning selection through ACP config options. */ +export class AcpModelControl { + /** Scoped selection reference consumed by Agent request assembly. */ + readonly selection: ModelSelectionRef + private tail = Promise.resolve() + private selected: ModelSelection | undefined + private turnSelection: { turn: number; selection: ModelSelection } | undefined + private hasResolvedState = false + + constructor( + private readonly llm: LlmRuntime, + initial: ModelSelection | undefined, + ) { + this.selected = initial + const getCurrent = (): ModelSelection | undefined => this.turnSelection?.selection ?? this.selected + const setCurrent = (value: ModelSelection | undefined): void => { this.selected = value } + this.selection = { + get current() { return getCurrent() }, + set current(value) { setCurrent(value) }, + assembled: undefined, + } + } + + /** + * Install request/prompt consistency listeners in the unpublished Agent scope. + * @param agentCtx - Agent scope that consumes this selection. + */ + install(agentCtx: Context): void { + installModelSelection(agentCtx, this.selection) + } + + /** + * Snapshot the selection attached to the next accepted ACP prompt. + * @returns a detached future selection, or undefined when listeners supply the route. + */ + snapshot(): ModelSelection | undefined { + return this.selected === undefined ? undefined : { ...this.selected } + } + + /** + * Pin one admitted ACP message's selection for every step in its turn. + * @param turn - admitted Agent turn. + * @param selection - exact prompt-admission selection. + */ + pinTurn(turn: number, selection: ModelSelection): void { + this.turnSelection = { turn, selection: { ...selection } } + } + + /** + * Release only the exact completed turn's routing override. + * @param turn - completed Agent turn. + */ + releaseTurn(turn: number): void { + if (this.turnSelection?.turn === turn) this.turnSelection = undefined + } + + /** + * Return the complete standard config-option state after prior mutations settle. + * @param signal - optional catalog and exact-model cancellation. + * @returns all current standard configuration options. + */ + options(signal?: AbortSignal): Promise { + return this.serialize(async () => (await this.state(signal)).options) + } + + /** + * Set one advertised option and return the complete resulting option state. + * @param configId - standard option id. + * @param value - opaque selected value returned by a previous option state. + * @param signal - optional catalog and exact-model cancellation. + * @returns all standard options after the serialized mutation. + */ + set(configId: string, value: unknown, signal?: AbortSignal): Promise { + return this.serialize(async () => { + if (typeof value !== 'string') throw new AcpModelConfigError(`${configId} requires a select value`) + const current = this.selected + if (current === undefined) throw new AcpModelConfigError('this session has no model selection') + if (configId === MODEL_CONFIG_ID) { + const state = await this.state(signal) + const selected = state.choices.get(value) + if (selected === undefined) throw new AcpModelConfigError(`unknown model option: ${value}`) + await this.resolveSelection(selected, signal) + this.selected = selected + } else if (configId === REASONING_CONFIG_ID) { + const info = await this.llm.resolveModelInfo(current.provider, current.model, signal) + const providerDefault = value === PROVIDER_DEFAULT_REASONING_VALUE + && info.reasoning?.defaultEffort === undefined + if ( + info.reasoning === undefined + || (!providerDefault && !info.reasoning.efforts.some(effort => effort.id === value)) + ) { + throw new AcpModelConfigError(`unknown reasoning effort for ${current.provider}/${current.model}: ${value}`) + } + this.selected = await this.resolveSelection({ + provider: current.provider, + model: current.model, + ...providerDefault ? {} : { reasoningEffort: ReasoningEffortId(value) }, + }, signal) + } else { + throw new AcpModelConfigError(`unknown session config option: ${configId}`) + } + return (await this.state(signal)).options + }) + } + + /** Keep concurrent client mutations in receive order without wedging after rejection. */ + private serialize(operation: () => Promise): Promise { + const result = this.tail.then(operation) + this.tail = result.then(() => undefined, () => undefined) + return result + } + + /** Build detached model choices and the dependent reasoning option. */ + private async state(signal?: AbortSignal): Promise { + const selected = this.selected + if (selected === undefined) return { choices: new Map(), options: [] } + let resolved: ModelSelection + let routeAvailable = true + try { + resolved = await this.resolveSelection(selected, signal) + this.hasResolvedState = true + } catch (error: unknown) { + if (!this.hasResolvedState) throw error + resolved = selected + routeAvailable = false + } + const choices = new Map() + const groups = await Promise.all(this.llm.listProviders().map(async (provider) => { + try { + const models = await this.llm.listModels(provider.id) + const entries = models.map((model) => { + const choice: ModelChoice = { + value: modelValue(provider.id, model.id), + selection: { provider: provider.id, model: model.id }, + } + choices.set(choice.value, choice.selection) + return { + value: choice.value, + name: model.name, + ...model.description === undefined ? {} : { description: model.description }, + } + }) + return { group: provider.id, name: provider.name, options: entries } + } catch (_providerCatalogUnavailable) { + return { group: provider.id, name: provider.name, options: [] } + } + })) + const currentValue = modelValue(resolved.provider, resolved.model) + if (!choices.has(currentValue)) { + choices.set(currentValue, { provider: resolved.provider, model: resolved.model }) + let group = groups.find(item => item.group === resolved.provider) + if (group === undefined) { + group = { group: resolved.provider, name: resolved.provider, options: [] } + groups.push(group) + } + group.options.unshift({ value: currentValue, name: resolved.model }) + } + const options: SessionConfigOption[] = [{ + id: MODEL_CONFIG_ID, + name: 'Model', + category: 'model', + type: 'select', + currentValue, + options: groups.filter(group => group.options.length > 0), + }] + const info = routeAvailable + ? await this.llm.resolveModelInfo(resolved.provider, resolved.model, signal) + : undefined + if (info?.reasoning !== undefined) { + options.push({ + id: REASONING_CONFIG_ID, + name: 'Reasoning effort', + category: 'thought_level', + type: 'select', + currentValue: resolved.reasoningEffort === undefined + ? PROVIDER_DEFAULT_REASONING_VALUE + : String(resolved.reasoningEffort), + options: [ + ...info.reasoning.defaultEffort === undefined + ? [{ value: PROVIDER_DEFAULT_REASONING_VALUE, name: 'Provider default' }] + : [], + ...info.reasoning.efforts.map(effort => ({ + value: String(effort.id), + name: effort.name, + ...effort.description === undefined ? {} : { description: effort.description }, + })), + ], + }) + } + return { choices, options } + } + + /** Validate an exact route and retain only Agent-owned selection fields. */ + private async resolveSelection(selection: ModelSelection, signal?: AbortSignal): Promise { + const resolved: LlmCallConfig = await this.llm.resolveCallConfig(selection, signal) + return { + provider: resolved.provider, + model: resolved.model, + ...resolved.reasoningEffort === undefined ? {} : { reasoningEffort: resolved.reasoningEffort }, + } + } +} + +/** Opaque ACP selector value carrying the full route identity. */ +function modelValue(provider: string, model: string): SessionConfigValueId { + return JSON.stringify([provider, model]) +} diff --git a/packages/acp/acp/src/session.ts b/packages/acp/acp/src/session.ts new file mode 100644 index 0000000000..a4e93f1e1a --- /dev/null +++ b/packages/acp/acp/src/session.ts @@ -0,0 +1,527 @@ +/** One standard ACP session's Agent, configuration, prompt, update, and teardown lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RequestError, + type McpServer, + type PromptRequest, + type PromptResponse, + type SessionConfigOption, + type SessionNotification, + type StopReason, +} from '@agentclientprotocol/sdk' +import type { Agent, AgentHandle, AgentOptions, ModelSelection } from '@deepseek-ai/dsh-agent' +import { createUserMessage, errorChain, type UserMessage } from '@deepseek-ai/dsh-llm' +import { type Session, type SessionEvent, type SessionId, type TurnEndReason } from '@deepseek-ai/dsh-session' +import { AcpContentError, admitAcpPrompt } from './content.ts' +import { turnEndToStopReason } from './codec.ts' +import { mountAcpMcpServers } from './mcp.ts' +import { AcpModelControl } from './model-control.ts' +import { assistantUpdates, toolCallUpdate, toolResultUpdate } from './updates.ts' + +/** The continuable-subagent teardown used without depending on the subagent package. */ +interface ContinuableDrain { + /** Dispose continuable descendants below exact host-owned parents child-first. */ + drainContinuableDescendants(parents: readonly Agent[]): Promise +} + +/** Inputs shared by fresh and resumed ACP session construction. */ +interface AcpSessionBuildOptions { + cwd: string + mcpServers: readonly McpServer[] + agentOptions: AgentOptions + fallbackSelection: ModelSelection | undefined + signal: AbortSignal + notify: (notification: SessionNotification) => Promise +} + +/** Fresh ACP session construction inputs. */ +export interface CreateAcpSessionOptions extends AcpSessionBuildOptions { + sessionId: SessionId +} + +/** Persisted ACP session construction inputs. */ +export interface ResumeAcpSessionOptions extends AcpSessionBuildOptions { + sessionId: SessionId +} + +interface InflightPrompt { + resolve: (reason: StopReason) => void + reject: (error: Error) => void + messageId: string | undefined + messageQueued: boolean + turn: number | undefined + endReason: TurnEndReason | undefined + admissionDone: Promise + finishAdmission: () => void + admissionController: AbortController + cancelRequested: boolean + settlementStarted: boolean + outputError: Error | undefined + agentError: Error | undefined +} + +/** Standard invalid-parameter failure with protocol-safe detail. */ +function invalidParams(detail: string): RequestError { + return RequestError.invalidParams(undefined, detail) +} + +/** Standard internal failure with protocol-safe detail. */ +function internalError(detail: string): RequestError { + return RequestError.internalError(undefined, detail) +} + +/** Restore the latest logged route before falling back to deployment config. */ +function selectionFor( + logged: { + config: { provider: string; model: string; reasoningEffort?: ModelSelection['reasoningEffort'] } + adapterDefaults?: { reasoningEffort?: boolean } + } | undefined, + fallback: ModelSelection | undefined, +): ModelSelection | undefined { + return logged === undefined + ? fallback + : { + provider: logged.config.provider, + model: logged.config.model, + ...logged.config.reasoningEffort === undefined || logged.adapterDefaults?.reasoningEffort === true + ? {} + : { reasoningEffort: logged.config.reasoningEffort }, + } +} + +/** + * Per-session ACP module. It owns the unpublished Agent composition, selected + * route, one-prompt admission slot, ordered standard updates, and memoized + * quiescent teardown. + */ +export class AcpSession { + /** The exact top-level Agent owned by this ACP session. */ + readonly agent: Agent + private readonly modelControl: AcpModelControl + private outputTail = Promise.resolve() + private inflight: InflightPrompt | undefined + private closing: Promise | undefined + private readonly pendingSelections = new Map() + + private constructor( + private readonly ctx: Context, + handle: AgentHandle, + modelControl: AcpModelControl, + private readonly notify: (notification: SessionNotification) => Promise, + ) { + this.agent = handle.agent + this.modelControl = modelControl + this.disposeAgent = () => handle.dispose() + } + + private readonly disposeAgent: () => Promise + + /** + * Compose a fresh Agent and all requested MCP clients before publication. + * @param ctx - ACP plugin context with Agent, LLM, and persistence services. + * @param options - fresh session identity, workspace, route, MCP, and notifier. + * @returns the fully composed per-session module. + */ + static async create(ctx: Context, options: CreateAcpSessionOptions): Promise { + const modelControl = new AcpModelControl(ctx.llm, options.fallbackSelection) + const handle = await ctx.agents.create({ + sessionId: options.sessionId, + meta: { cwd: options.cwd }, + agentOptions: options.agentOptions, + signal: options.signal, + setup: async (agentCtx) => { + modelControl.install(agentCtx) + await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) + }, + }) + return new AcpSession(ctx, handle, modelControl, options.notify) + } + + /** + * Restore a persisted Agent and compose the request's fresh MCP connections. + * @param ctx - ACP plugin context with Agent, LLM, and persistence services. + * @param options - persisted identity, workspace, fallback route, MCP, and notifier. + * @returns the restored per-session module. + */ + static async resume(ctx: Context, options: ResumeAcpSessionOptions): Promise { + let modelControl: AcpModelControl | undefined + const handle = await ctx.agents.resume({ + resumeSessionId: options.sessionId, + agentOptions: options.agentOptions, + signal: options.signal, + setup: async (agentCtx) => { + const agent = agentCtx.agent + /* v8 ignore next -- Agent factory setup always carries its unpublished Agent. */ + if (agent === undefined) throw new Error('acp: resumed Agent is absent during setup') + modelControl = new AcpModelControl( + ctx.llm, + selectionFor(agent.session.requestHeader(), options.fallbackSelection), + ) + modelControl.install(agentCtx) + await mountAcpMcpServers(agentCtx, options.mcpServers, options.cwd) + }, + }) + /* v8 ignore start -- a fulfilled Agent resume necessarily ran setup to completion. */ + if (modelControl === undefined) { + await handle.dispose() + throw internalError('session/resume did not compose model selection') + } + /* v8 ignore stop */ + return new AcpSession(ctx, handle, modelControl, options.notify) + } + + /** + * Whether this module owns an exact Agent reference. + * @param agent - Agent observed on a scoped runtime event. + * @returns true only for this session's owned Agent. + */ + owns(agent: Agent): boolean { + return this.agent === agent + } + + /** + * Whether this module owns an exact Session reference. + * @param session - Session observed on a durable event. + * @returns true only for this session's owned Session. + */ + ownsSession(session: Session): boolean { + return this.agent.session === session + } + + /** + * Return the complete standard model configuration state. + * @param signal - optional request cancellation. + * @returns provider-grouped model and exact-model reasoning options. + */ + configOptions(signal?: AbortSignal): Promise { + this.assertActive() + return this.modelControl.options(signal) + } + + /** + * Apply one standard configuration option to later ACP turns. + * @param configId - advertised standard option id. + * @param value - selected standard option value. + * @param signal - optional request cancellation. + * @returns the complete resulting option state. + */ + setConfig(configId: string, value: unknown, signal?: AbortSignal): Promise { + this.assertActive() + return this.modelControl.set(configId, value, signal) + } + + /** Resolve topology state off-chain, then serialize its notification without blocking execution updates. */ + topologyChanged(): void { + if (this.closing !== undefined) return + void this.modelControl.options() + .then((configOptions) => { + if (this.closing !== undefined) return + const previous = this.outputTail + this.outputTail = previous + .then(() => this.notify({ + sessionId: this.agent.session.id, + update: { sessionUpdate: 'config_option_update', configOptions }, + })) + /* v8 ignore start -- the bridge notifier contains transport failure. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: config-option update failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + }) + /* v8 ignore start -- option discovery contains per-provider failure. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: config-option update failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + } + + /** + * Admit, enqueue, and settle one prompt at whole-Agent quiescence. + * @param params - standard ACP prompt request for this session. + * @param imageEnabled - connection capability advertised at initialization. + * @param requestSignal - JSON-RPC request cancellation signal. + * @returns the correlated standard stop reason after ordered updates drain. + */ + async prompt( + params: PromptRequest, + imageEnabled: boolean, + requestSignal?: AbortSignal, + ): Promise { + this.assertActive() + if (this.inflight !== undefined) throw invalidParams('a prompt is already in flight for this session') + const completion = Promise.withResolvers() + const admission = Promise.withResolvers() + const admissionController = new AbortController() + const inflight: InflightPrompt = { + resolve: completion.resolve, + reject: completion.reject, + messageId: undefined, + messageQueued: false, + turn: undefined, + endReason: undefined, + admissionDone: admission.promise, + finishAdmission: admission.resolve, + admissionController, + cancelRequested: false, + settlementStarted: false, + outputError: undefined, + agentError: undefined, + } + this.inflight = inflight + const onRequestAbort = (): void => { this.cancelPrompt('ACP prompt request cancelled') } + requestSignal?.addEventListener('abort', onRequestAbort, { once: true }) + /* v8 ignore next -- the SDK dispatches a live signal, then notifies abort through its listener. */ + if (requestSignal?.aborted === true) onRequestAbort() + try { + let admissionFailure: unknown + const promptSelection = this.modelControl.snapshot() + try { + if (this.ctx.agents.get(this.agent.id) !== this.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const content = await admitAcpPrompt( + this.ctx, + promptSelection, + params.prompt, + imageEnabled, + admissionController.signal, + ) + admissionController.signal.throwIfAborted() + if (this.ctx.agents.get(this.agent.id) !== this.agent) { + throw internalError('prompt was not queued: the agent was disposed outside the bridge') + } + const message = createUserMessage({ + content, + source: { kind: 'user' }, + }) + inflight.messageId = message.id + inflight.messageQueued = true + if (promptSelection !== undefined) this.pendingSelections.set(message.id, promptSelection) + try { + this.agent.followup(message) + } catch (error: unknown) { + inflight.messageQueued = false + this.pendingSelections.delete(message.id) + throw error + } + } catch (error: unknown) { + admissionFailure = error + } finally { + inflight.finishAdmission() + } + + if (inflight.cancelRequested) { + this.settleAfterQuiescence(inflight) + return { stopReason: await completion.promise } + } + if (admissionFailure !== undefined) { + this.inflight = undefined + if (admissionFailure instanceof AcpContentError) { + throw admissionFailure.kind === 'invalid' + ? invalidParams(admissionFailure.message) + : internalError(admissionFailure.message) + } + if (admissionFailure instanceof RequestError) throw admissionFailure + throw internalError(`prompt was not queued: ${(admissionFailure as Error).message}`) + } + + this.settleAfterQuiescence(inflight) + return { stopReason: await completion.promise } + } finally { + requestSignal?.removeEventListener('abort', onRequestAbort) + } + } + + /** Cancel the active prompt, or autonomous work when no ACP prompt exists. */ + cancel(): void { + const inflight = this.inflight + this.cancelPrompt('ACP prompt cancelled') + if (inflight === undefined) this.agent.cancel({ kind: 'user' }) + } + + /** + * Process one durable event and enqueue its standard ACP projections. + * @param session - exact event-owning Session. + * @param event - committed durable event. + */ + onSessionEvent(session: Session, event: SessionEvent): void { + try { + if (event.type === 'assistant/message') { + const inflight = this.inflight?.turn === event.data.turn ? this.inflight : undefined + const previous = this.outputTail + const delivery = previous.then(async () => { + for (const update of await assistantUpdates(this.ctx, session, event)) { + await this.notify({ sessionId: this.agent.session.id, update }) + } + }) + this.outputTail = delivery.catch((error: unknown) => { + const failure = error as Error + if (inflight !== undefined) inflight.outputError ??= failure + this.ctx.logger.warn(`acp: assistant output conversion failed: ${errorChain(error)}`) + }) + } else if (event.type === 'tool/call') { + const previous = this.outputTail + this.outputTail = previous + .then(() => this.notify({ sessionId: this.agent.session.id, update: toolCallUpdate(event) })) + /* v8 ignore start -- the bridge notifier contains transport rejection. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: tool-call update delivery failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + } else if (event.type === 'tool/result') { + const previous = this.outputTail + this.outputTail = previous + .then(async () => this.notify({ + sessionId: this.agent.session.id, + update: await toolResultUpdate(this.ctx, event), + })) + /* v8 ignore start -- supplemental-content conversion failure is contained and cannot fail Agent work. */ + .catch((error: unknown) => { + this.ctx.logger.warn(`acp: tool-result update delivery failed: ${errorChain(error)}`) + }) + /* v8 ignore stop */ + } + } finally { + const inflight = this.inflight + if (inflight !== undefined && event.type === 'turn/end' && inflight.turn === event.data.turn) { + inflight.endReason = event.data.reason + } + if (event.type === 'turn/end') this.modelControl.releaseTurn(event.data.turn) + } + } + + /** + * Correlate an accepted user message with its Agent turn and pinned route. + * @param message - claimed durable inbox message. + * @param turn - allocated Agent turn. + */ + onInboxClaimed(message: UserMessage, turn: number): void { + if (this.inflight !== undefined && this.inflight.messageId === message.id) this.inflight.turn = turn + const selection = this.pendingSelections.get(message.id) + this.pendingSelections.delete(message.id) + if (selection !== undefined) this.modelControl.pinTurn(turn, selection) + } + + /** + * Correlate an Agent interval failure with the active ACP prompt. + * @param turn - failed turn number. + * @param error - original same-process failure. + */ + onAgentError(turn: number, error: unknown): void { + const inflight = this.inflight + if (inflight === undefined || !inflight.messageQueued) return + // AgentLoop balances an in-turn failure with durable turn/end; settlement + // reads that exact error reason. This slot records interval failures outside it. + if (inflight.turn === turn) return + inflight.agentError = new Error(errorChain(error)) + this.settleAfterQuiescence(inflight) + } + + /** Await every update queued before this call. */ + drainUpdates(): Promise { + return this.outputTail + } + + /** + * Cancel, drain, flush, and dispose this session once. + * @param detail - cancellation detail for any prompt still in admission. + * @returns the shared quiescent teardown promise. + */ + close(detail: string): Promise { + if (this.closing !== undefined) return this.closing + this.closing = (async () => { + const failures: unknown[] = [] + const inflight = this.inflight + this.cancelPrompt(detail) + if (inflight === undefined || !inflight.messageQueued) this.agent.cancel({ kind: 'user' }) + try { + await inflight?.admissionDone + await this.agent.whenIdle() + await this.outputTail + } catch (error: unknown) { + failures.push(new Error('ACP session activity drain failed', { cause: error })) + } + const subagents = this.ctx.get('subagents') as ContinuableDrain | undefined + try { + await subagents?.drainContinuableDescendants([this.agent]) + } catch (error: unknown) { + this.ctx.logger.warn(`acp: continuable subagent teardown failed: ${errorChain(error)}`) + failures.push(new Error('continuable subagent teardown failed', { cause: error })) + } + try { + await this.ctx.sessions.flush(this.agent.session) + } catch (error: unknown) { + failures.push(new Error('ACP session persistence flush failed', { cause: error })) + } + try { + await this.disposeAgent() + } catch (error: unknown) { + failures.push(error) + } + this.pendingSelections.clear() + if (failures.length === 1) throw failures[0] + /* v8 ignore start -- independent teardown failures can aggregate only under multiple simultaneous provider faults. */ + if (failures.length > 1) { + throw new AggregateError(failures, `ACP session teardown failed: ${failures.map(errorChain).join('; ')}`) + } + /* v8 ignore stop */ + })() + return this.closing + } + + private assertActive(): void { + if (this.closing !== undefined) throw invalidParams(`session is closing: ${this.agent.session.id}`) + } + + private cancelPrompt(detail: string): void { + const inflight = this.inflight + if (inflight === undefined) return + inflight.cancelRequested = true + inflight.admissionController.abort(new Error(detail)) + this.settleAfterQuiescence(inflight) + if (inflight.messageQueued) this.agent.cancel({ kind: 'user' }) + } + + private settleAfterQuiescence(inflight: InflightPrompt): void { + if (inflight.settlementStarted) return + inflight.settlementStarted = true + void (async () => { + await inflight.admissionDone + if (inflight.messageQueued) { + await this.agent.whenIdle() + await this.outputTail + } + /* v8 ignore next -- this prompt owns the slot until this exact settlement clears it. */ + if (this.inflight !== inflight) return + this.inflight = undefined + if (inflight.cancelRequested) { + inflight.resolve('cancelled') + return + } + if (inflight.outputError !== undefined) { + inflight.reject(internalError(`assistant output delivery failed: ${inflight.outputError.message}`)) + return + } + if (inflight.agentError !== undefined) { + inflight.reject(internalError(`turn failed: ${inflight.agentError.message}`)) + return + } + const end = inflight.endReason + if (end === undefined) { + inflight.resolve('cancelled') + } else if (end.kind === 'error') { + inflight.reject(internalError(`turn failed: ${end.error.message}`)) + } else { + inflight.resolve(turnEndToStopReason(end)) + } + })() + /* v8 ignore start -- admissionDone only resolves; idle/output gates contain their own failures. */ + .catch((error: unknown) => { + if (this.inflight !== inflight) return + this.inflight = undefined + inflight.reject(internalError(`prompt settlement failed: ${errorChain(error)}`)) + }) + /* v8 ignore stop */ + } +} diff --git a/packages/acp/acp/src/updates.ts b/packages/acp/acp/src/updates.ts new file mode 100644 index 0000000000..09687078f1 --- /dev/null +++ b/packages/acp/acp/src/updates.ts @@ -0,0 +1,111 @@ +/** Standard ACP updates derived from committed DSH session events. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionUpdate, ToolCallContent } from '@agentclientprotocol/sdk' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-token-meter' +import { assistantBlockToAcp } from './content.ts' + +/** + * Convert one committed assistant message and its context usage in block order. + * @param ctx - bridge context carrying attachment and token-meter services. + * @param session - durable session used for context pressure. + * @param event - committed assistant message event. + * @returns ordered standard thought, message, and optional usage updates. + */ +export async function assistantUpdates( + ctx: Context, + session: Session, + event: SessionEvent<'assistant/message'>, +): Promise { + const updates: SessionUpdate[] = [] + for (const block of event.data.message.content) { + if (block.type === 'reasoning') { + if (block.text.length > 0) { + updates.push({ + sessionUpdate: 'agent_thought_chunk', + messageId: event.data.message.id, + content: { type: 'text', text: block.text }, + }) + } + continue + } + const content = await assistantBlockToAcp(ctx, block) + if (content !== undefined) { + updates.push({ + sessionUpdate: 'agent_message_chunk', + messageId: event.data.message.id, + content, + }) + } + } + const usage = usageUpdate(ctx, session, event) + if (usage !== undefined) updates.push(usage) + return updates +} + +/** + * Start one generic ACP tool lifecycle from the durable call fact. + * @param event - committed DSH tool-call event. + * @returns the standard generic tool-call update. + */ +export function toolCallUpdate(event: SessionEvent<'tool/call'>): SessionUpdate { + return { + sessionUpdate: 'tool_call', + toolCallId: event.data.callId, + title: event.data.name, + kind: 'other', + status: 'in_progress', + rawInput: parseToolArguments(event.data.arguments), + } +} + +/** + * Finish one generic ACP tool lifecycle from its committed model-facing result. + * @param ctx - bridge context carrying the attachment store. + * @param event - committed DSH tool-result event. + * @returns the standard completed or failed tool-call update. + */ +export async function toolResultUpdate( + ctx: Context, + event: SessionEvent<'tool/result'>, +): Promise { + const result = event.data.message.content[0] + const content: ToolCallContent[] = [] + for (const block of result.content) { + const converted = await assistantBlockToAcp(ctx, block) + if (converted !== undefined) content.push({ type: 'content' as const, content: converted }) + } + return { + sessionUpdate: 'tool_call_update', + toolCallId: result.toolCallId, + status: result.isError === true ? 'failed' : 'completed', + content, + } +} + +/** Report current context occupancy only when DSH has both usage and capacity facts. */ +function usageUpdate( + ctx: Context, + session: Session, + event: SessionEvent<'assistant/message'>, +): SessionUpdate | undefined { + if (event.data.usage === undefined) return undefined + const size = session.requestContext()?.contextWindow + const meter = ctx.get('tokenMeter') + if (size === undefined || meter === undefined) return undefined + return { + sessionUpdate: 'usage_update', + used: meter.measure(session).totalTokens, + size, + } +} + +/** Preserve malformed model output as opaque input instead of dropping the call update. */ +function parseToolArguments(value: string): unknown { + try { + return JSON.parse(value) as unknown + } catch (_invalidModelJson) { + return value + } +} diff --git a/packages/acp/acp/tests/approval.spec.ts b/packages/acp/acp/tests/approval.spec.ts index ea1ec994a4..22f1973b77 100644 --- a/packages/acp/acp/tests/approval.spec.ts +++ b/packages/acp/acp/tests/approval.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import ApprovalService, { type ApprovalRequest } from '@deepseek-ai/dsh-user-approval' @@ -21,12 +21,20 @@ describe('ACP machine permission policy', () => { const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) const agent = harness.ctx.agents.get(SessionId(sessionId))! agent.session.append('turn/start', { turn: 1 }) - return { agent, toolName: 'bash', callId: CallId('call-9'), ...overrides } + agent.session.append('step/start', { turn: 1, step: 1 }) + agent.session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('call-9'), name: 'bash', arguments: '{}' }) + return { agent, toolName: 'bash', callId: ToolCallId('call-9'), ...overrides } } it('maps the two advertised one-shot choices', async () => { harness = await makeBridgeHarness() - harness.onPermission = () => ({ outcome: { outcome: 'selected', optionId: 'allow-once' } }) + harness.onPermission = () => { + expect(harness?.sessionUpdates.at(-1)?.update).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'call-9', + }) + return { outcome: { outcome: 'selected', optionId: 'allow-once' } } + } const request = await ownedRequest() await expect(harness.ctx.approval.request(request)).resolves.toBe('allowed-once') expect(harness.permissionRequests[0]).toMatchObject({ @@ -63,7 +71,7 @@ describe('ACP machine permission policy', () => { const foreign = { session: { id: request.agent.session.id, events: [{ type: 'turn/start' }], append: () => ({}) }, } as unknown as Agent - await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: CallId('call') })) + await expect(harness.ctx.approval.request({ agent: foreign, toolName: 'bash', callId: ToolCallId('call') })) .resolves.toBe('unavailable') expect(harness.permissionRequests).toHaveLength(0) }) diff --git a/packages/acp/acp/tests/bridge.spec.ts b/packages/acp/acp/tests/bridge.spec.ts index 2823f717db..85125b3661 100644 --- a/packages/acp/acp/tests/bridge.spec.ts +++ b/packages/acp/acp/tests/bridge.spec.ts @@ -1,8 +1,28 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' import { AttachmentError } from '@deepseek-ai/dsh-attachment' +import { ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' +import { startHttpMcpFixture } from '../../../mcp/mcp-client/tests/http-fixture.ts' + +function oneToolCall(): StreamChunk[] { + return [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: ToolCallId('call-switch'), name: 'switch_model', argumentsDelta: '{}' }, + { + type: 'block-end', + index: 0, + block: { type: 'tool-call', id: ToolCallId('call-switch'), name: 'switch_model', arguments: '{}' }, + }, + { type: 'finish', reason: { kind: 'tool-calls' } }, + ] +} describe('automation-only ACP bridge', () => { let harness: BridgeHarness | undefined @@ -12,7 +32,7 @@ describe('automation-only ACP bridge', () => { harness = undefined }) - it('advertises only fresh text sessions', async () => { + it('advertises the standard automation controls without private metadata', async () => { harness = await makeBridgeHarness() const response = await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -23,7 +43,9 @@ describe('automation-only ACP bridge', () => { protocolVersion: PROTOCOL_VERSION, agentInfo: { name: 'deepseek-harness-acp', version: '0.0.1' }, agentCapabilities: { + mcpCapabilities: { http: true }, promptCapabilities: { image: false, audio: false, embeddedContext: false }, + sessionCapabilities: { close: {}, list: {}, resume: {} }, }, authMethods: [], }) @@ -57,15 +79,733 @@ describe('automation-only ACP bridge', () => { }) expect(result.stopReason).toBe('end_turn') - await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) }) - expect(harness.updates).toEqual([{ + await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') }) + expect(harness.updates[0]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'hello there' }, - }]) + }) + expect('messageId' in harness.updates[0]!).toBe(true) + if ('messageId' in harness.updates[0]!) expect(typeof harness.updates[0].messageId).toBe('string') expect(harness.ctx.agents.get(SessionId(sessionId))?.session.header.cwd).toBe(process.cwd()) expect(harness.adapter.requests[0]?.messages.at(-1)?.content).toEqual([{ type: 'text', text: 'say hello' }]) }) + it('closes one active session without affecting its neighbor', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.client.closeSession({ sessionId: first.sessionId }) + + expect(harness.ctx.agents.get(SessionId(first.sessionId))).toBeUndefined() + expect(harness.ctx.agents.get(SessionId(second.sessionId))).toBeDefined() + await expect(harness.client.prompt({ + sessionId: first.sessionId, + prompt: [{ type: 'text', text: 'closed' }], + })).rejects.toThrow(/unknown session/) + }) + + it('cancels a running prompt and makes its session resumable before close returns', async () => { + harness = await makeBridgeHarness({ script: ['hang', textResponse('resumed')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const prompt = harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'hang' }] }) + await vi.waitFor(() => { + expect(harness!.ctx.agents.get(SessionId(created.sessionId))?.status).toBe('running') + }) + + await harness.client.closeSession({ sessionId: created.sessionId }) + + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + await expect(harness.client.listSessions({})).resolves.toMatchObject({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [] }) + }) + + it('shares one close operation and rejects new work while close is draining', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const flushing: PromiseWithResolvers = Promise.withResolvers() + const flush = vi.spyOn(harness.ctx.sessions, 'flush').mockImplementationOnce(() => flushing.promise.then(() => true)) + + const first = harness.client.closeSession({ sessionId: created.sessionId }) + await vi.waitFor(() => { expect(flush).toHaveBeenCalled() }) + const second = harness.client.closeSession({ sessionId: created.sessionId }) + harness.registerCatalogProvider('closing-topology') + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'too late' }], + })).rejects.toThrow(/session is closing/) + flushing.resolve() + + await expect(Promise.all([first, second])).resolves.toEqual([{}, {}]) + }) + + it('disposes the Agent and reports an explicit close drain failure', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(created.sessionId))! + vi.spyOn(agent, 'whenIdle').mockRejectedValueOnce(new Error('idle probe failed')) + + await expect(harness.client.closeSession({ sessionId: created.sessionId })).rejects.toThrow(/session close failed/) + + expect(harness.ctx.agents.get(SessionId(created.sessionId))).toBeUndefined() + }) + + it('resumes a closed persisted session without replaying its history', async () => { + harness = await makeBridgeHarness({ script: [textResponse('first answer'), textResponse('second answer')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first prompt' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const updatesBeforeResume = harness.updates.length + + const resumed = await harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [], + }) + expect(Array.isArray(resumed.configOptions)).toBe(true) + expect(harness.updates).toHaveLength(updatesBeforeResume) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second prompt' }] }) + + expect(harness.adapter.requests[1]?.messages.map(message => message.content)).toContainEqual([ + { type: 'text', text: 'first prompt' }, + ]) + }) + + it('materializes an empty closed session for list and resume', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await harness.client.closeSession({ sessionId: created.sessionId }) + + await expect(harness.client.listSessions({})).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .resolves.toHaveProperty('configOptions') + }) + + it('rejects active or wrong-workspace resume before composing another Agent', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [], + })).rejects.toThrow(/already active/) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const resume = vi.spyOn(harness.ctx.agents, 'resume') + + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: tmpdir(), + mcpServers: [], + })).rejects.toThrow(/cwd does not match/) + expect(resume).not.toHaveBeenCalled() + + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: `${process.cwd()}/packages/..`, + mcpServers: [], + })).resolves.toHaveProperty('configOptions') + }) + + it('reserves a persisted id across concurrent resume admission', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const resume = harness.ctx.agents.resume.bind(harness.ctx.agents) + const entered: PromiseWithResolvers = Promise.withResolvers() + const release: PromiseWithResolvers = Promise.withResolvers() + vi.spyOn(harness.ctx.agents, 'resume').mockImplementationOnce(async (options) => { + entered.resolve() + await release.promise + return resume(options) + }) + + const first = harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + await entered.promise + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .rejects.toThrow(/already active/) + await expect(harness.client.listSessions({})).resolves.toEqual({ sessions: [] }) + release.resolve() + + await expect(first).resolves.toHaveProperty('configOptions') + }) + + it('excludes a globally live session owned outside this ACP bridge', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const sessionId = SessionId('other-frontend-live') + harness.ctx.sessions.create(sessionId, { meta: { cwd: process.cwd() } }) + vi.spyOn(harness.ctx.sessionPersistence, 'list').mockResolvedValue([{ + version: 0, + id: sessionId, + createdAt: 1, + cwd: process.cwd(), + }]) + const resume = vi.spyOn(harness.ctx.agents, 'resume') + + await expect(harness.client.listSessions({})).resolves.toEqual({ sessions: [] }) + await expect(harness.client.resumeSession({ + sessionId, + cwd: process.cwd(), + mcpServers: [], + })).rejects.toThrow(/already active/) + expect(resume).not.toHaveBeenCalled() + }) + + it('rejects unknown resume ids and rolls back invalid resume MCP', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(harness.client.resumeSession({ + sessionId: 'missing', + cwd: process.cwd(), + })).rejects.toThrow(/not resumable/) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + const duplicate = { name: 'same', command: process.execPath, args: [], env: [] } + + await expect(harness.client.resumeSession({ + sessionId: created.sessionId, + cwd: process.cwd(), + mcpServers: [duplicate, duplicate], + })).rejects.toThrow(/duplicate normalized name/) + expect(harness.ctx.agents.list()).toHaveLength(0) + }) + + it('restores the deployment selection when persisted events have no request header', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const agent = harness.ctx.agents.get(SessionId(created.sessionId))! + agent.session.append('session/title', { title: 'materialized', messageSeqs: [], source: { kind: 'fallback' } }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + + expect(resumed.configOptions?.find(option => option.id === 'model')).toMatchObject({ + currentValue: '["mock","mock"]', + }) + }) + + it('restores an explicitly selected reasoning effort', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: 'low', + }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + const resumed = await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() }) + + expect(resumed.configOptions?.find(option => option.id === 'reasoning_effort')).toMatchObject({ + currentValue: 'low', + }) + }) + + it('lists closed persisted sessions without presentation metadata', async () => { + harness = await makeBridgeHarness({ script: [textResponse('answer')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist me' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + await expect(harness.client.listSessions({})).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + }) + + it('paginates resumable sessions with an opaque deterministic cursor', async () => { + harness = await makeBridgeHarness({ + config: { sessionListPageSize: 1 }, + script: [textResponse('first'), textResponse('second')], + }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: first.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.closeSession({ sessionId: first.sessionId }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: second.sessionId, prompt: [{ type: 'text', text: 'second' }] }) + await harness.client.closeSession({ sessionId: second.sessionId }) + + const firstPage = await harness.client.listSessions({}) + expect(firstPage.sessions).toHaveLength(1) + expect(firstPage.nextCursor).toEqual(expect.any(String)) + if (typeof firstPage.nextCursor !== 'string') throw new Error('expected a pagination cursor') + const secondPage = await harness.client.listSessions({ cursor: firstPage.nextCursor }) + expect(secondPage.sessions).toHaveLength(1) + expect(secondPage.nextCursor).toBeUndefined() + expect(new Set([...firstPage.sessions, ...secondPage.sessions].map(item => item.sessionId))) + .toEqual(new Set([first.sessionId, second.sessionId])) + await expect(harness.client.listSessions({ cursor: 'not-a-cursor' })).rejects.toThrow(/cursor is invalid/) + }) + + it('filters non-resumable headers and canonical missing workspaces', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const active = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const persistence = harness.ctx.get('sessionPersistence')! + vi.spyOn(persistence, 'list').mockResolvedValue([ + { version: 0, id: SessionId(active.sessionId), createdAt: 9, cwd: process.cwd() }, + { version: 0, id: SessionId('subagent'), createdAt: 8, cwd: '/missing/filter', origin: 'subagent' }, + { version: 0, id: SessionId('fork'), createdAt: 7, cwd: '/missing/filter', parentSession: SessionId('parent') }, + { version: 0, id: SessionId('no-cwd'), createdAt: 6 }, + { version: 0, id: SessionId('relative'), createdAt: 5, cwd: 'relative' }, + { version: 0, id: SessionId('other'), createdAt: 4, cwd: '/missing/other' }, + { version: 0, id: SessionId('valid-b'), createdAt: 3, cwd: '/missing/filter' }, + { version: 0, id: SessionId('valid-a'), createdAt: 3, cwd: '/missing/filter' }, + ]) + + await expect(harness.client.listSessions({ cwd: 'relative' })).rejects.toThrow(/absolute path/) + await expect(harness.client.listSessions({ cwd: '/missing/filter' })).resolves.toEqual({ + sessions: [ + { sessionId: 'valid-a', cwd: '/missing/filter' }, + { sessionId: 'valid-b', cwd: '/missing/filter' }, + ], + }) + await expect(harness.client.resumeSession({ + sessionId: 'no-cwd', + cwd: '/missing/filter', + })).rejects.toThrow(/cwd does not match/) + }) + + it.each([ + [null], + [[]], + [['not-a-number', 'id']], + [[-1, 'id']], + [[1, '']], + ] as const)('rejects malformed decoded list cursors %#', async (decoded) => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const cursor = Buffer.from(JSON.stringify(decoded)).toString('base64url') + await expect(harness.client.listSessions({ cursor })).rejects.toThrow(/cursor is invalid/) + }) + + it('rejects invalid and non-canonical cursor encodings', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(harness.client.listSessions({ cursor: '*' })).rejects.toThrow(/cursor is invalid/) + const bytes = Buffer.from(JSON.stringify([1, 'id'])) + const canonical = bytes.toString('base64url') + const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_' + const nonCanonical = alphabet.split('') + .map(char => canonical.slice(0, -1) + char) + .find(candidate => candidate !== canonical && Buffer.from(candidate, 'base64url').equals(bytes)) + if (nonCanonical === undefined) throw new Error('expected an alternate base64url spelling') + + await expect(harness.client.listSessions({ cursor: nonCanonical })).rejects.toThrow(/cursor is invalid/) + }) + + it('rolls back new and resume when configuration discovery fails', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const resolve = vi.spyOn(harness.ctx.llm, 'resolveCallConfig') + resolve.mockRejectedValueOnce(new Error('catalog resolution failed')) + await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })) + .rejects.toThrow(/Internal error/) + expect(harness.ctx.agents.list()).toHaveLength(0) + await expect(harness.ctx.sessionPersistence.list()).resolves.toEqual([]) + + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + resolve.mockRejectedValueOnce(new Error('resume catalog failed')) + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .rejects.toThrow(/Internal error/) + expect(harness.ctx.agents.list()).toHaveLength(0) + }) + + it('propagates non-MCP Agent factory failures and non-config selection failures', async () => { + harness = await makeBridgeHarness({ script: [textResponse('persisted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const create = vi.spyOn(harness.ctx.agents, 'create') + create.mockRejectedValueOnce(new Error('factory create failed')) + await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [] })) + .rejects.toThrow(/Internal error/) + + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected model options') + const plain = model.options.flatMap(option => 'group' in option ? option.options : [option]) + .find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected plain model') + const resolution = vi.spyOn(harness.ctx.llm, 'resolveCallConfig').mockRejectedValue(new Error('selection failed')) + await expect(harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + })).rejects.toThrow(/Internal error/) + resolution.mockRestore() + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'persist' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + vi.spyOn(harness.ctx.agents, 'resume').mockRejectedValueOnce(new Error('factory resume failed')) + await expect(harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd() })) + .rejects.toThrow(/Internal error/) + }) + + it('lists and resumes persisted sessions after an equivalent process restart', async () => { + const persistenceRoot = await mkdtemp(join(tmpdir(), 'dsh-acp-restart-')) + try { + harness = await makeBridgeHarness({ persistenceRoot, script: [textResponse('before restart')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + await harness.dispose() + + harness = await makeBridgeHarness({ persistenceRoot, script: [textResponse('after restart')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + await expect(harness.client.listSessions({})).resolves.toEqual({ + sessions: [{ sessionId: created.sessionId, cwd: process.cwd() }], + }) + await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers: [] }) + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'second' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + } finally { + await harness?.dispose() + harness = undefined + await rm(persistenceRoot, { recursive: true, force: true }) + } + }) + + it('discovers and selects a session model through standard config options', async () => { + harness = await makeBridgeHarness({ script: [textResponse('plain answer')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model select option') + const choices = model.options.flatMap(option => 'group' in option ? option.options : [option]) + const plain = choices.find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected Mock Plain in the model catalog') + + const selected = await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }) + expect(selected.configOptions.find(option => option.id === 'reasoning_effort')).toBeUndefined() + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use plain' }] }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' }) + }) + + it('publishes complete config options when adapter topology changes', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + harness.registerCatalogProvider('other') + + await vi.waitFor(() => { + const update = harness!.updates.find(item => item.sessionUpdate === 'config_option_update') + expect(update).toBeDefined() + if (update?.sessionUpdate !== 'config_option_update') return + const model = update.configOptions.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model select option') + expect(model.options.some(option => 'group' in option && option.group === 'other')).toBe(true) + }) + expect(harness.sessionUpdates.at(-1)?.sessionId).toBe(created.sessionId) + }) + + it('does not let hung topology discovery block prompt completion or close', async () => { + harness = await makeBridgeHarness({ script: [textResponse('still responsive')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const original = harness.ctx.llm.listModels.bind(harness.ctx.llm) + const blocked = Promise.withResolvers>>() + const listModels = vi.spyOn(harness.ctx.llm, 'listModels').mockImplementation((provider: string) => ( + provider === 'hung' ? blocked.promise : original(provider) + )) + + try { + harness.registerCatalogProvider('hung') + await vi.waitFor(() => { expect(listModels).toHaveBeenCalledWith('hung') }) + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'continue while discovery is pending' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + await expect(harness.client.closeSession({ sessionId: created.sessionId })).resolves.toEqual({}) + } finally { + blocked.resolve([]) + listModels.mockRestore() + } + }) + + it('publishes recoverable options when the selected adapter disappears', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + harness.registerCatalogProvider('other') + await vi.waitFor(() => { + expect(harness!.updates.some(update => update.sessionUpdate === 'config_option_update')).toBe(true) + }) + + harness.replacePrimaryProviders([]) + expect(harness.ctx.llm.listProviders().map(provider => provider.id)).toEqual(['other']) + + await vi.waitFor(() => { + const configUpdates = harness!.updates.filter(item => item.sessionUpdate === 'config_option_update') + expect(configUpdates).toHaveLength(2) + const update = configUpdates.at(-1) + if (update?.sessionUpdate !== 'config_option_update') throw new Error('expected config update') + const model = update.configOptions.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected model options') + const groups = model.options.filter(option => 'group' in option) + expect(groups.map(group => group.group)).toEqual(['other', 'mock']) + expect(model.currentValue).toBe('["mock","mock"]') + }) + expect(harness.sessionUpdates.at(-1)?.sessionId).toBe(created.sessionId) + }) + + it('selects an advertised reasoning effort for the next turn', async () => { + harness = await makeBridgeHarness({ script: [textResponse('reasoned')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const reasoning = created.configOptions?.find(option => option.id === 'reasoning_effort') + if (reasoning?.type !== 'select') throw new Error('expected a reasoning select option') + const low = reasoning.options.find(option => !('group' in option) && option.name === 'Low') + if (low === undefined || 'group' in low) throw new Error('expected Low reasoning effort') + + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: low.value, + }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'reason' }] }) + + expect(harness.adapter.requests[0]?.reasoningEffort).toBe('low') + }) + + it('rejects unknown config choices without changing the selected route', async () => { + harness = await makeBridgeHarness({ script: [textResponse('unchanged')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + await expect(harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: 'not-advertised', + })).rejects.toThrow(/unknown model option/) + await expect(harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'private_option', + value: 'anything', + })).rejects.toThrow(/unknown session config option/) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'go' }] }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + }) + + it('serializes concurrent standard config changes in receive order', async () => { + harness = await makeBridgeHarness({ script: [textResponse('plain')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + const reasoning = created.configOptions?.find(option => option.id === 'reasoning_effort') + if (model?.type !== 'select' || reasoning?.type !== 'select') throw new Error('expected model and reasoning options') + const plain = model.options.flatMap(option => 'group' in option ? option.options : [option]) + .find(option => option.name === 'Mock Plain') + const low = reasoning.options.find(option => !('group' in option) && option.name === 'Low') + if (plain === undefined || low === undefined || 'group' in low) throw new Error('expected selectable values') + + await Promise.all([ + harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'reasoning_effort', + value: low.value, + }), + harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }), + ]) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'go' }] }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'plain' }) + expect(harness.adapter.requests[0]?.reasoningEffort).toBeUndefined() + }) + + it('pins image admission and request routing to one prompt selection', async () => { + harness = await makeBridgeHarness({ imageCapable: true, script: [textResponse('image accepted')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model option') + const plain = model.options.flatMap(option => 'group' in option ? option.options : [option]) + .find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected Mock Plain') + const validationStarted = Promise.withResolvers() + const releaseValidation = Promise.withResolvers() + harness.attachments!.beforeValidate = () => { + validationStarted.resolve(undefined) + return releaseValidation.promise + } + + const prompt = harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }], + }) + await validationStarted.promise + await harness.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }) + releaseValidation.resolve(undefined) + await expect(prompt).resolves.toEqual({ stopReason: 'end_turn' }) + + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + harness.attachments!.beforeValidate = undefined + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'image', data: 'Ag==', mimeType: 'image/png' }], + })).rejects.toThrow(/does not declare image input/) + }) + + it('applies a mid-turn model change to the following turn', async () => { + harness = await makeBridgeHarness({ script: [oneToolCall(), textResponse('first turn'), textResponse('second turn')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + const model = created.configOptions?.find(option => option.id === 'model') + if (model?.type !== 'select') throw new Error('expected a model select option') + const choices = model.options.flatMap(option => 'group' in option ? option.options : [option]) + const plain = choices.find(option => option.name === 'Mock Plain') + if (plain === undefined) throw new Error('expected Mock Plain in the model catalog') + harness.ctx.tools.register(defineContentToolFixture({ + name: 'switch_model', + description: 'Switch the following turn to the plain model.', + parameters: {}, + execute: async () => { + await harness!.client.setSessionConfigOption({ + sessionId: created.sessionId, + configId: 'model', + value: plain.value, + }) + return [{ type: 'text', text: 'selected' }] + }, + })) + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] }) + + expect(harness.adapter.requests.map(request => request.model)).toEqual(['mock', 'mock', 'plain']) + }) + + it('mounts a standard stdio MCP server inside the created session', async () => { + harness = await makeBridgeHarness({ script: [textResponse('used MCP')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + const created = await harness.client.newSession({ + cwd: process.cwd(), + mcpServers: [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }], + }) + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use MCP' }] }) + + expect(harness.adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('mcp__fixture__add') + await harness.client.closeSession({ sessionId: created.sessionId }) + }, 30_000) + + it('mounts a standard Streamable HTTP MCP server with request headers', async () => { + const fixture = await startHttpMcpFixture() + try { + harness = await makeBridgeHarness({ script: [textResponse('used HTTP MCP')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ + cwd: process.cwd(), + mcpServers: [{ + type: 'http', + name: 'web', + url: fixture.url, + headers: [{ name: 'Authorization', value: 'Bearer acp-test' }], + }], + }) + + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'use HTTP MCP' }] }) + + expect(harness.adapter.requests[0]?.tools?.map(tool => tool.name)).toContain('mcp__web__ping') + expect(fixture.authorization).toContain('Bearer acp-test') + await harness.client.closeSession({ sessionId: created.sessionId }) + } finally { + await fixture.close() + } + }, 30_000) + + it('allows the same MCP server namespace in independent sessions', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + const mcpServers = [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }] + + const first = await harness.client.newSession({ cwd: process.cwd(), mcpServers }) + const second = await harness.client.newSession({ cwd: process.cwd(), mcpServers }) + + await Promise.all([ + harness.client.closeSession({ sessionId: first.sessionId }), + harness.client.closeSession({ sessionId: second.sessionId }), + ]) + }, 30_000) + + it('validates standard MCP declarations before publishing an Agent', async () => { + harness = await makeBridgeHarness() + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const stdio = { name: 'fixture', command: process.execPath, args: [], env: [] } + const invalidLists = [ + [stdio, stdio], + [{ ...stdio, name: ' ' }], + [{ ...stdio, command: 'node' }], + [{ ...stdio, env: [{ name: 'BAD=NAME', value: 'x' }] }], + [{ type: 'http' as const, name: 'web', url: 'file:///tmp/mcp', headers: [] }], + [{ type: 'http' as const, name: 'web', url: 'https://example.test/mcp', headers: [{ name: 'bad header', value: 'x' }] }], + [{ type: 'sse' as const, name: 'legacy', url: 'https://example.test/sse', headers: [] }], + [{ type: 'acp' as const, name: 'nested', serverId: 'server-1' }], + ] + for (const mcpServers of invalidLists) { + await expect(harness.client.newSession({ + cwd: process.cwd(), + mcpServers, + })).rejects.toThrow(/mcpServers/) + expect(harness.ctx.agents.list()).toHaveLength(0) + } + }) + + it('reconnects requested MCP servers when resuming a closed session', async () => { + harness = await makeBridgeHarness({ script: [textResponse('first'), textResponse('second')] }) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const fixtureServer = fileURLToPath(new URL('../../../mcp/mcp-client/tests/fixture-server.ts', import.meta.url)) + const mcpServers = [{ name: 'fixture', command: process.execPath, args: [fixtureServer], env: [] }] + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'first' }] }) + await harness.client.closeSession({ sessionId: created.sessionId }) + + await harness.client.resumeSession({ sessionId: created.sessionId, cwd: process.cwd(), mcpServers }) + await harness.client.prompt({ sessionId: created.sessionId, prompt: [{ type: 'text', text: 'second' }] }) + + expect(harness.adapter.requests[1]?.tools?.map(tool => tool.name)).toContain('mcp__fixture__add') + }, 30_000) + it('leaves absent agent targets for request listeners to supply', async () => { harness = await makeBridgeHarness({ config: { provider: undefined, model: undefined } }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -74,6 +814,27 @@ describe('automation-only ACP bridge', () => { expect(harness.ctx.agents.get(SessionId(sessionId))?.options).toEqual({}) }) + it('allows request listeners to supply a route when ACP has no initial selection', async () => { + harness = await makeBridgeHarness({ + config: { provider: undefined, model: undefined }, + script: [textResponse('listener-routed')], + }) + harness.ctx.on('agent/request', async (_payload, next) => ({ + ...await next(), + provider: 'mock', + model: 'mock', + })) + await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const created = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) + + expect(created.configOptions).toEqual([]) + await expect(harness.client.prompt({ + sessionId: created.sessionId, + prompt: [{ type: 'text', text: 'route me' }], + })).resolves.toEqual({ stopReason: 'end_turn' }) + expect(harness.adapter.requests[0]).toMatchObject({ provider: 'mock', model: 'mock' }) + }) + it('concatenates text blocks without exposing protocol framing to the model', async () => { harness = await makeBridgeHarness({ script: [textResponse('done')] }) await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -164,7 +925,7 @@ describe('automation-only ACP bridge', () => { expect(harness.adapter.requests[0]?.system).toContain(`Automation persona for mock in ${process.cwd()}.`) }) - it('requires one absolute workspace and no MCP servers', async () => { + it('requires one absolute primary workspace', async () => { harness = await makeBridgeHarness() await harness.client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) @@ -174,11 +935,6 @@ describe('automation-only ACP bridge', () => { mcpServers: [], additionalDirectories: ['/tmp/other'], })).rejects.toThrow(/additionalDirectories/) - await expect(harness.client.newSession({ - cwd: process.cwd(), - mcpServers: [{ name: 'fs', command: 'node', args: [], env: [] }], - })).rejects.toThrow(/mcpServers/) - await expect(harness.client.newSession({ cwd: process.cwd(), mcpServers: [], diff --git a/packages/acp/acp/tests/content.spec.ts b/packages/acp/acp/tests/content.spec.ts index a22dbe9069..144559b6e0 100644 --- a/packages/acp/acp/tests/content.spec.ts +++ b/packages/acp/acp/tests/content.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { Context } from '@deepseek-ai/cordis' import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentRef, SaveImageAttachment } from '@deepseek-ai/dsh-attachment' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type { ModelSelection } from '@deepseek-ai/dsh-agent' import { AcpContentError, admitAcpPrompt, @@ -20,7 +20,7 @@ const REF: ImageAttachmentRef = { interface AdmissionFixture { ctx: Context - agent: Agent + route: ModelSelection | undefined saveImages: ReturnType Promise>> resolveModelInfo: ReturnType } @@ -30,7 +30,6 @@ function admissionFixture(options: { llm?: boolean provider?: string | undefined model?: string | undefined - header?: { provider?: string; model?: string } } = {}): AdmissionFixture { const saveImages = vi.fn(async (inputs: readonly SaveImageAttachment[]) => inputs.map((input, index) => ({ ...REF, @@ -55,11 +54,8 @@ function admissionFixture(options: { } as unknown as Context const provider = 'provider' in options ? options.provider : 'mock' const model = 'model' in options ? options.model : 'vision' - const agent = { - options: { provider, model }, - session: { requestHeader: () => options.header === undefined ? undefined : { config: options.header } }, - } as unknown as Agent - return { ctx, agent, saveImages, resolveModelInfo } + const route = provider === undefined || model === undefined ? undefined : { provider, model } + return { ctx, route, saveImages, resolveModelInfo } } describe('ACP rich content codec', () => { @@ -93,19 +89,19 @@ describe('ACP rich content codec', () => { const fixture = admissionFixture() const signal = new AbortController().signal - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'AQ==', mimeType: 'image/tiff' }, ] as never, true, signal)).rejects.toThrow(/mimeType/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'not base64', mimeType: 'image/png' }, ], true, signal)).rejects.toThrow(/canonical base64/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'AB==', mimeType: 'image/png' }, ], true, signal)).rejects.toThrow(/canonical base64/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'audio', data: 'AQ==', mimeType: 'audio/wav' }, ], true, signal)).rejects.toThrow(/audio prompt/) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'resource', resource: { uri: 'file:///tmp/a', text: 'a' } }, ], true, signal)).rejects.toThrow(/embedded resource/) expect(fixture.saveImages).not.toHaveBeenCalled() @@ -114,41 +110,41 @@ describe('ACP rich content codec', () => { it('requires the advertised capability, store, and exact image-capable route', async () => { const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const const capable = admissionFixture() - await expect(admitAcpPrompt(capable.ctx, capable.agent, prompt, false, new AbortController().signal)) + await expect(admitAcpPrompt(capable.ctx, capable.route, prompt, false, new AbortController().signal)) .rejects.toThrow(/not advertised/) const noStore = admissionFixture({ attachments: false }) - await expect(admitAcpPrompt(noStore.ctx, noStore.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noStore.ctx, noStore.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/no attachment store/) const noProvider = admissionFixture({ provider: undefined }) - await expect(admitAcpPrompt(noProvider.ctx, noProvider.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noProvider.ctx, noProvider.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/route could not be resolved/) const noModel = admissionFixture({ model: undefined }) - await expect(admitAcpPrompt(noModel.ctx, noModel.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noModel.ctx, noModel.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/route could not be resolved/) const noLlm = admissionFixture({ llm: false }) - await expect(admitAcpPrompt(noLlm.ctx, noLlm.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(noLlm.ctx, noLlm.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/route could not be resolved/) const broken = admissionFixture() broken.resolveModelInfo.mockRejectedValueOnce(new Error('catalog down')) - const routeFailure = admitAcpPrompt(broken.ctx, broken.agent, prompt, true, new AbortController().signal) + const routeFailure = admitAcpPrompt(broken.ctx, broken.route, prompt, true, new AbortController().signal) await expect(routeFailure).rejects.toMatchObject({ kind: 'internal' }) await expect(routeFailure).rejects.toThrow(/route could not be verified/) const unknown = admissionFixture() unknown.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision' }) - await expect(admitAcpPrompt(unknown.ctx, unknown.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(unknown.ctx, unknown.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/does not declare image input/) const textOnly = admissionFixture() textOnly.resolveModelInfo.mockResolvedValueOnce({ provider: 'mock', id: 'vision', name: 'vision', inputModalities: ['text'], }) - await expect(admitAcpPrompt(textOnly.ctx, textOnly.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(textOnly.ctx, textOnly.route, prompt, true, new AbortController().signal)) .rejects.toThrow(/does not declare image input/) - const routed = admissionFixture({ provider: 'fallback', model: 'fallback', header: { provider: 'live', model: 'vision-2' } }) - await expect(admitAcpPrompt(routed.ctx, routed.agent, prompt, true, new AbortController().signal)).resolves.toHaveLength(1) + const routed = admissionFixture({ provider: 'live', model: 'vision-2' }) + await expect(admitAcpPrompt(routed.ctx, routed.route, prompt, true, new AbortController().signal)).resolves.toHaveLength(1) expect(routed.resolveModelInfo).toHaveBeenCalledWith('live', 'vision-2', expect.any(AbortSignal)) }) @@ -156,16 +152,16 @@ describe('ACP rich content codec', () => { const fixture = admissionFixture() const prompt = [{ type: 'image', data: 'AQ==', mimeType: 'image/png' }] as const fixture.saveImages.mockRejectedValueOnce(new AttachmentError('too many', 'TOO_MANY_IMAGES')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'invalid', message: 'too many' }) fixture.saveImages.mockRejectedValueOnce(new AttachmentError('disk failed', 'ATTACHMENT_WRITE_FAILED')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) fixture.saveImages.mockRejectedValueOnce(new AttachmentError('corrupt object', 'ATTACHMENT_CORRUPT')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toMatchObject({ kind: 'internal', message: 'unable to persist the prompt image batch' }) fixture.saveImages.mockRejectedValueOnce(new Error('unknown store failure')) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, prompt, true, new AbortController().signal)) + await expect(admitAcpPrompt(fixture.ctx, fixture.route, prompt, true, new AbortController().signal)) .rejects.toBeInstanceOf(AcpContentError) }) @@ -174,7 +170,7 @@ describe('ACP rich content codec', () => { const before = admissionFixture() const beforeController = new AbortController() beforeController.abort(new Error('cancel before write')) - await expect(admitAcpPrompt(before.ctx, before.agent, prompt, true, beforeController.signal)) + await expect(admitAcpPrompt(before.ctx, before.route, prompt, true, beforeController.signal)) .rejects.toThrow('cancel before write') expect(before.saveImages).not.toHaveBeenCalled() @@ -184,19 +180,19 @@ describe('ACP rich content codec', () => { afterController.abort(new Error('cancel after write')) return [REF] }) - await expect(admitAcpPrompt(after.ctx, after.agent, prompt, true, afterController.signal)) + await expect(admitAcpPrompt(after.ctx, after.route, prompt, true, afterController.signal)) .rejects.toThrow('cancel after write') expect(after.saveImages).toHaveBeenCalledOnce() }) it('reconstructs image-only and baseline prompts without empty text blocks', async () => { const fixture = admissionFixture() - const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.agent, [ + const imageOnly = await admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'image', data: 'AQ==', mimeType: 'image/png' }, ], true, new AbortController().signal) expect(imageOnly).toHaveLength(1) expect(imageOnly[0]?.type).toBe('image') - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'text', text: 'before' }, { type: 'resource_link', name: 'Guide', uri: 'https://example.test/guide' }, { type: 'text', text: 'after' }, @@ -204,7 +200,7 @@ describe('ACP rich content codec', () => { type: 'text', text: 'before\n[resource_link name="Guide" uri="https://example.test/guide"]\nafter', }]) - await expect(admitAcpPrompt(fixture.ctx, fixture.agent, [ + await expect(admitAcpPrompt(fixture.ctx, fixture.route, [ { type: 'text', text: ' \n ' }, ], true, new AbortController().signal)).rejects.toThrow(/empty prompt/) }) diff --git a/packages/acp/acp/tests/edges.spec.ts b/packages/acp/acp/tests/edges.spec.ts index 84bbff3b3d..073a5893c9 100644 --- a/packages/acp/acp/tests/edges.spec.ts +++ b/packages/acp/acp/tests/edges.spec.ts @@ -1,15 +1,19 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -import { createUserMessage, CallId, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { createUserMessage, ToolCallId, type StreamChunk } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import { makeBridgeHarness, textResponse, type BridgeHarness } from './harness.ts' function toolCallResponse(): StreamChunk[] { return [ - { type: 'block-start', index: 0, blockType: 'tool-call' }, - { type: 'tool-call-delta', index: 0, id: CallId('call-1'), name: 'echo', argumentsDelta: '{}' }, - { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('call-1'), name: 'echo', arguments: '{}' } }, + { type: 'block-start', index: 0, blockType: 'reasoning' }, + { type: 'reasoning-delta', index: 0, text: 'inspect first' }, + { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'inspect first' } }, + { type: 'block-start', index: 1, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 1, id: ToolCallId('call-1'), name: 'echo', argumentsDelta: '{}' }, + { type: 'block-end', index: 1, block: { type: 'tool-call', id: ToolCallId('call-1'), name: 'echo', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 8, outputTokens: 2, reasoningTokens: 1 } }, { type: 'finish', reason: { kind: 'tool-calls' } }, ] } @@ -22,7 +26,7 @@ describe('ACP automation output boundary', () => { harness = undefined }) - it('does not emit tool, terminal, plan, title, or reasoning presentation updates', async () => { + it('emits committed reasoning, generic tool lifecycle, usage, and final text in order', async () => { harness = await makeBridgeHarness({ script: [toolCallResponse(), textResponse('done')] }) harness.ctx.tools.register(defineContentToolFixture({ name: 'echo', @@ -34,11 +38,45 @@ describe('ACP automation output boundary', () => { const { sessionId } = await harness.client.newSession({ cwd: process.cwd(), mcpServers: [] }) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - await vi.waitFor(() => { expect(harness!.updates).toHaveLength(1) }) - expect(harness.updates).toEqual([{ + await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') }) + expect(harness.updates.map(update => update.sessionUpdate)).toEqual([ + 'agent_thought_chunk', + 'usage_update', + 'tool_call', + 'tool_call_update', + 'agent_message_chunk', + 'usage_update', + ]) + expect(harness.updates[0]).toMatchObject({ + sessionUpdate: 'agent_thought_chunk', + content: { type: 'text', text: 'inspect first' }, + }) + expect('messageId' in harness.updates[0]!).toBe(true) + expect(harness.updates[2]).toMatchObject({ + sessionUpdate: 'tool_call', + toolCallId: 'call-1', + title: 'echo', + kind: 'other', + status: 'in_progress', + rawInput: {}, + }) + expect(harness.updates[3]).toMatchObject({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-1', + status: 'completed', + content: [{ type: 'content', content: { type: 'text', text: 'tool result' } }], + }) + expect(harness.updates[4]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'done' }, - }]) + }) + expect('messageId' in harness.updates[4]!).toBe(true) + expect(harness.updates[5]).toMatchObject({ + sessionUpdate: 'usage_update', + size: 1_024, + }) + if (harness.updates[5]?.sessionUpdate !== 'usage_update') throw new Error('expected usage update') + expect(typeof harness.updates[5].used).toBe('number') }) it('ignores events from agents the bridge does not own', async () => { @@ -62,11 +100,13 @@ describe('ACP automation output boundary', () => { agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'plugin', plugin: 'test' } })) await agent.whenIdle() + await vi.waitFor(() => { expect(harness!.updates.at(-1)?.sessionUpdate).toBe('usage_update') }) - expect(harness.updates).toEqual([{ + expect(harness.updates[0]).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'external' }, - }]) + }) + expect('messageId' in harness.updates[0]!).toBe(true) }) it('contains output conversion failure outside an ACP prompt', async () => { diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index ce6e93794f..70e31cbbe4 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -2,21 +2,29 @@ import { Context } from '@deepseek-ai/cordis' import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { - ClientSideConnection, + client as createAcpClientApp, + methods, ndJsonStream, type Agent as AcpAgent, - type Client, + type PromptRequest, + type PromptResponse, type RequestPermissionRequest, type RequestPermissionResponse, + type SendRequestOptions, type SessionNotification, type Stream, } from '@agentclientprotocol/sdk' import AttachmentStore, { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' -import { type GenerateOptions, LlmAdapter, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' +import { type GenerateOptions, LlmAdapter, ReasoningEffortId, type LlmResolvedModelInfo, type StreamChunk } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import TokenMeter from '@deepseek-ai/dsh-token-meter' import * as AcpPlugin from '../src/index.ts' import type { AcpConfig } from '../src/index.ts' @@ -27,22 +35,32 @@ class MockAdapter extends LlmAdapter { constructor( private readonly script: (StreamChunk[] | 'hang')[], private readonly imageCapable: boolean, + private readonly provider = 'mock', ) { super() } override providerInfo(provider: string) { - if (provider !== 'mock') throw new Error(`MockAdapter: unknown provider ${provider}`) - return { id: 'mock', name: 'Mock' } + if (provider !== this.provider) throw new Error(`MockAdapter: unknown provider ${provider}`) + return { id: this.provider, name: this.provider === 'mock' ? 'Mock' : `Mock ${this.provider}` } } override listModels(provider: string) { - return Promise.resolve(provider === 'mock' ? [{ - provider: 'mock', - id: 'mock', - name: 'Mock', - inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const, - }] : []) + return Promise.resolve(provider === this.provider ? [ + { + provider: this.provider, + id: 'mock', + name: 'Mock Reasoner', + description: 'Mock model with selectable reasoning.', + inputModalities: this.imageCapable ? ['text', 'image'] as const : ['text'] as const, + }, + { + provider: this.provider, + id: 'plain', + name: 'Mock Plain', + inputModalities: ['text'] as const, + }, + ] : []) } override resolveModel(provider: string, model: string): Promise { @@ -50,7 +68,17 @@ class MockAdapter extends LlmAdapter { provider, id: model, name: model, - inputModalities: this.imageCapable ? ['text', 'image'] : ['text'], + inputModalities: this.imageCapable && model === 'mock' ? ['text', 'image'] : ['text'], + context: { contextWindow: 1_024 }, + ...model === 'mock' ? { + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + } : {}, }) } @@ -153,16 +181,32 @@ export function errorResponse(message: string): StreamChunk[] { export type CapturedUpdate = SessionNotification['update'] +/** Stable-v1 client methods exercised by the bridge tests. */ +interface BridgeClient { + initialize: NonNullable + authenticate: NonNullable + newSession: NonNullable + listSessions: NonNullable + resumeSession: NonNullable + closeSession: NonNullable + setSessionConfigOption: NonNullable + prompt: (params: PromptRequest, options?: SendRequestOptions) => Promise + cancel: NonNullable +} + export interface BridgeHarness { ctx: Context - client: ClientSideConnection + client: BridgeClient adapter: MockAdapter attachments: MemoryAttachmentStore | undefined updates: CapturedUpdate[] sessionUpdates: { sessionId: string; update: CapturedUpdate }[] permissionRequests: RequestPermissionRequest[] + persistenceRoot: string onPermission: (request: RequestPermissionRequest) => RequestPermissionResponse onSessionUpdateError: (() => void) | undefined + registerCatalogProvider: (provider: string) => () => void + replacePrimaryProviders: (providers: string[]) => void closeClientTransport: () => Promise abortClientTransport: () => Promise acpFiber: Awaited> @@ -180,13 +224,18 @@ export async function makeBridgeHarness(options: { persona?: string imageCapable?: boolean attachments?: boolean + persistenceRoot?: string } = {}): Promise { const adapter = new MockAdapter(options.script ?? [], options.imageCapable === true) const ctx = new Context() + const ownsPersistenceRoot = options.persistenceRoot === undefined + const persistenceRoot = options.persistenceRoot ?? await mkdtemp(join(tmpdir(), 'dsh-acp-test-')) await mountAgentLoopTestDependencies(ctx, { systemPrompt: { persona: options.persona ?? '' } }) + await ctx.plugin(JsonlSessionPersistence, { root: persistenceRoot, compression: 'none' }) + await ctx.plugin(TokenMeter) if (options.attachments !== false) await ctx.plugin(MemoryAttachmentStore) const loopFiber = await ctx.plugin(AgentLoop, { agents: [] }) - ctx.llm.registerAdapter(['mock'], adapter) + const primaryAdapter = ctx.llm.registerAdapter(['mock'], adapter) const agentToClient = new TransformStream() const clientToAgent = new TransformStream() @@ -207,28 +256,33 @@ export async function makeBridgeHarness(options: { updates, sessionUpdates, permissionRequests, + persistenceRoot, onPermission: () => ({ outcome: { outcome: 'cancelled' } }), onSessionUpdateError: undefined, - client: undefined as unknown as ClientSideConnection, + registerCatalogProvider: provider => ctx.llm.registerAdapter([provider], new MockAdapter([], false, provider)), + replacePrimaryProviders: (providers) => { primaryAdapter.replace(providers) }, + client: undefined as unknown as BridgeClient, acpFiber: undefined as unknown as BridgeHarness['acpFiber'], loopFiber, closeClientTransport: async () => { await clientToAgentWriter.close() }, abortClientTransport: async () => { await clientToAgentWriter.abort(new Error('client transport failed')) }, - dispose: async () => { await ctx.fiber.dispose() }, + dispose: async () => { + await ctx.fiber.dispose() + if (ownsPersistenceRoot) await rm(persistenceRoot, { recursive: true, force: true }) + }, } - const makeClient = (_agent: AcpAgent): Client => ({ - sessionUpdate(params: SessionNotification): Promise { + const clientApp = createAcpClientApp({ name: 'dsh-acp-test-client' }) + .onNotification(methods.client.session.update, ({ params }) => { updates.push(params.update) sessionUpdates.push({ sessionId: params.sessionId, update: params.update }) if (harness.onSessionUpdateError !== undefined) return Promise.reject(new Error('client update rejected')) return Promise.resolve() - }, - requestPermission(params: RequestPermissionRequest): Promise { + }) + .onRequest(methods.client.session.requestPermission, ({ params }) => { permissionRequests.push(params) return Promise.resolve(harness.onPermission(params)) - }, - }) + }) const config = { stream: agentStream, ...options.config } as AcpConfig if (!(options.config && 'provider' in options.config)) config.provider = 'mock' @@ -238,6 +292,18 @@ export async function makeBridgeHarness(options: { inject: [...AcpPlugin.inject], apply: (inner: Context) => { AcpPlugin.apply(inner, config) }, }) - harness.client = new ClientSideConnection(makeClient, clientStream) + const clientConnection = clientApp.connect(clientStream) + const client = clientConnection.agent + harness.client = { + initialize: params => client.request(methods.agent.initialize, params), + authenticate: params => client.request(methods.agent.authenticate, params), + newSession: params => client.request(methods.agent.session.new, params), + listSessions: params => client.request(methods.agent.session.list, params), + resumeSession: params => client.request(methods.agent.session.resume, params), + closeSession: params => client.request(methods.agent.session.close, params), + setSessionConfigOption: params => client.request(methods.agent.session.setConfigOption, params), + prompt: (params, options) => client.request(methods.agent.session.prompt, params, options), + cancel: params => client.notify(methods.agent.session.cancel, params), + } return harness } diff --git a/packages/acp/acp/tests/mcp.spec.ts b/packages/acp/acp/tests/mcp.spec.ts new file mode 100644 index 0000000000..ffbe3d818a --- /dev/null +++ b/packages/acp/acp/tests/mcp.spec.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import type { McpServer } from '@agentclientprotocol/sdk' +import type { Config as McpClientConfig } from '@deepseek-ai/dsh-mcp-client' +import { mountAcpMcpServers } from '../src/mcp.ts' + +/** Context stand-in that captures validated MCP configs without opening transports. */ +function captureContext(): { ctx: Context; configs: McpClientConfig[] } { + const configs: McpClientConfig[] = [] + const plugin = vi.fn((_plugin: unknown, config: McpClientConfig) => { + configs.push(config) + return Promise.resolve(undefined) + }) + return { ctx: { plugin } as unknown as Context, configs } +} + +describe('ACP MCP declaration mapping', () => { + it('normalizes human server names and preserves standard stdio/HTTP fields', async () => { + const { ctx, configs } = captureContext() + + await mountAcpMcpServers(ctx, [ + { + name: 'Fancy server!', + command: process.execPath, + args: ['server.js'], + env: [{ name: 'TOKEN', value: 'secret' }], + }, + { + type: 'http', + name: '!!!', + url: 'https://example.test/mcp', + headers: [{ name: 'Authorization', value: 'Bearer token' }], + }, + ], process.cwd()) + + expect(configs).toHaveLength(2) + expect(configs[0]).toMatchObject({ + transport: 'stdio', + command: process.execPath, + args: ['server.js'], + env: { TOKEN: 'secret' }, + cwd: process.cwd(), + failOnStartupError: true, + }) + expect(configs[0]?.serverName).toMatch(/^Fancy_server_[0-9a-f]{8}$/) + expect(configs[1]).toMatchObject({ + transport: 'streamable-http', + url: 'https://example.test/mcp', + headers: { Authorization: 'Bearer token' }, + failOnStartupError: true, + }) + expect(configs[1]?.serverName).toMatch(/^server_[0-9a-f]{8}$/) + }) + + it.each([ + [[{ name: 'A', value: '1' }, { name: 'A', value: '2' }], /duplicate name/], + [[{ name: '', value: '1' }], /invalid environment entry/], + [[{ name: 'A\0', value: '1' }], /invalid environment entry/], + [[{ name: 'A', value: '1\0' }], /invalid environment entry/], + ] as const)('rejects invalid environment entries %#', async (env, message) => { + const { ctx } = captureContext() + await expect(mountAcpMcpServers(ctx, [{ + name: 'fixture', command: process.execPath, args: [], env: [...env], + }], process.cwd())).rejects.toThrow(message) + }) + + it('rejects case-insensitive duplicate headers and malformed URLs', async () => { + const { ctx } = captureContext() + await expect(mountAcpMcpServers(ctx, [{ + type: 'http', + name: 'web', + url: 'https://example.test/mcp', + headers: [{ name: 'X-Key', value: 'one' }, { name: 'x-key', value: 'two' }], + }], process.cwd())).rejects.toThrow(/duplicate name/) + await expect(mountAcpMcpServers(ctx, [{ + type: 'http', name: 'web', url: 'not a URL', headers: [], + }], process.cwd())).rejects.toThrow(/absolute HTTP/) + }) + + it('preserves legal names that collide with Object prototype setters', async () => { + const { ctx, configs } = captureContext() + + await mountAcpMcpServers(ctx, [ + { + name: 'stdio', + command: process.execPath, + args: [], + env: [{ name: '__proto__', value: 'environment-value' }], + }, + { + type: 'http', + name: 'http', + url: 'https://example.test/mcp', + headers: [{ name: '__proto__', value: 'header-value' }], + }, + ], process.cwd()) + + expect(configs[0]?.transport === 'stdio' && Object.hasOwn(configs[0].env, '__proto__')).toBe(true) + expect(configs[0]?.transport === 'stdio' && configs[0].env['__proto__']).toBe('environment-value') + expect(configs[1]?.transport === 'streamable-http' && Object.hasOwn(configs[1].headers, '__proto__')).toBe(true) + expect(configs[1]?.transport === 'streamable-http' && configs[1].headers['__proto__']).toBe('header-value') + }) + + it('maps provider schema failures into the indexed declaration error', async () => { + const { ctx } = captureContext() + const malformed = { + name: 'fixture', + command: process.execPath, + args: 'not-an-array', + env: [], + } as unknown as McpServer + + await expect(mountAcpMcpServers(ctx, [malformed], process.cwd())) + .rejects.toThrow(/mcpServers\[0\] is invalid/) + }) +}) diff --git a/packages/acp/acp/tests/model-control.spec.ts b/packages/acp/acp/tests/model-control.spec.ts new file mode 100644 index 0000000000..51db21271d --- /dev/null +++ b/packages/acp/acp/tests/model-control.spec.ts @@ -0,0 +1,131 @@ +import { describe, expect, it, vi } from 'vitest' +import { ReasoningEffortId, type LlmRuntime } from '@deepseek-ai/dsh-llm' +import { AcpModelControl } from '../src/model-control.ts' + +/** Minimal LLM catalog/runtime double for pure standard-option tests. */ +function llmRuntime(overrides: Partial = {}): LlmRuntime { + return { + listProviders: () => [{ id: 'mock', name: 'Mock' }], + listModels: () => Promise.resolve([{ provider: 'mock', id: 'mock', name: 'Mock' }]), + resolveCallConfig: (selection: { provider?: string; model?: string; reasoningEffort?: string }) => Promise.resolve({ + provider: selection.provider ?? 'mock', + model: selection.model ?? 'mock', + ...selection.reasoningEffort === undefined + ? { reasoningEffort: ReasoningEffortId('high') } + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }, + }), + resolveModelInfo: (provider: string, model: string) => Promise.resolve({ + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low', description: 'Less thought.' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + defaultEffort: ReasoningEffortId('high'), + }, + }), + ...overrides, + } as unknown as LlmRuntime +} + +describe('ACP model configuration control', () => { + it('represents an absent route and validates value types before mutation', async () => { + const control = new AcpModelControl(llmRuntime(), undefined) + + expect(control.snapshot()).toBeUndefined() + await expect(control.options()).resolves.toEqual([]) + await expect(control.set('model', false)).rejects.toThrow(/requires a select value/) + await expect(control.set('model', 'missing')).rejects.toThrow(/no model selection/) + + control.selection.current = { provider: 'mock', model: 'mock' } + expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' }) + }) + + it('synthesizes an unlisted current route and exposes reasoning descriptions', async () => { + const control = new AcpModelControl(llmRuntime({ listProviders: () => [] }), { + provider: 'private', + model: 'unlisted', + }) + + const options = await control.options() + + const model = options.find(option => option.id === 'model') + const reasoning = options.find(option => option.id === 'reasoning_effort') + expect(model).toMatchObject({ + type: 'select', + currentValue: '["private","unlisted"]', + options: [{ group: 'private', name: 'private', options: [{ name: 'unlisted' }] }], + }) + expect(reasoning).toMatchObject({ + type: 'select', + currentValue: 'high', + options: [{ name: 'Low', description: 'Less thought.' }, { name: 'High' }], + }) + + control.pinTurn(3, { provider: 'turn', model: 'pinned' }) + expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' }) + control.releaseTurn(2) + expect(control.selection.current).toEqual({ provider: 'turn', model: 'pinned' }) + control.releaseTurn(3) + expect(control.selection.current).toEqual({ provider: 'private', model: 'unlisted' }) + }) + + it('keeps the selected route when its provider catalog is temporarily unavailable', async () => { + const listModels = vi.fn(() => Promise.reject(new Error('catalog unavailable'))) + const control = new AcpModelControl(llmRuntime({ listModels }), { provider: 'mock', model: 'mock' }) + + const options = await control.options() + + expect(listModels).toHaveBeenCalledWith('mock') + expect(options[0]).toMatchObject({ + type: 'select', + options: [{ group: 'mock', options: [{ name: 'mock' }] }], + }) + }) + + it('rejects an unadvertised reasoning effort and accepts a later valid change', async () => { + const control = new AcpModelControl(llmRuntime(), { provider: 'mock', model: 'mock' }) + + await expect(control.set('reasoning_effort', 'extreme')).rejects.toThrow(/unknown reasoning effort/) + const options = await control.set('reasoning_effort', 'low') + + expect(options.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: 'low' }) + }) + + it('exposes and restores a provider-owned reasoning default', async () => { + const runtime = llmRuntime({ + resolveCallConfig: (selection: { provider?: string; model?: string; reasoningEffort?: string }) => Promise.resolve({ + provider: selection.provider ?? 'mock', + model: selection.model ?? 'mock', + ...selection.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }, + }), + resolveModelInfo: (provider: string, model: string) => Promise.resolve({ + provider, + id: model, + name: model, + reasoning: { + efforts: [ + { id: ReasoningEffortId('low'), name: 'Low' }, + { id: ReasoningEffortId('high'), name: 'High' }, + ], + }, + }), + }) + const control = new AcpModelControl(runtime, { provider: 'mock', model: 'mock' }) + + const initial = await control.options() + expect(initial.find(option => option.id === 'reasoning_effort')).toMatchObject({ + currentValue: '', + options: [{ value: '', name: 'Provider default' }, { value: 'low' }, { value: 'high' }], + }) + await control.set('reasoning_effort', 'low') + const restored = await control.set('reasoning_effort', '') + + expect(restored.find(option => option.id === 'reasoning_effort')).toMatchObject({ currentValue: '' }) + expect(control.selection.current).toEqual({ provider: 'mock', model: 'mock' }) + }) +}) diff --git a/packages/acp/acp/tests/turns.spec.ts b/packages/acp/acp/tests/turns.spec.ts index c72b4b9da3..8a44856590 100644 --- a/packages/acp/acp/tests/turns.spec.ts +++ b/packages/acp/acp/tests/turns.spec.ts @@ -31,13 +31,11 @@ describe('ACP prompt lifecycle', () => { harness = undefined }) - it('maps a max-token turn to end_turn without losing its committed text', async () => { + it('reports a max-token turn without losing its committed text', async () => { harness = await makeBridgeHarness({ script: [maxTokensResponse('cut off')] }) const sessionId = await newSession(harness) const result = await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'go' }] }) - // A token-limit turn ending is not a prompt-level stop reason (README): - // the prompt settles at whole-agent idle with end_turn. - expect(result.stopReason).toBe('end_turn') + expect(result.stopReason).toBe('max_tokens') await vi.waitFor(() => { expect(messageText(harness!)).toBe('cut off') }) }) @@ -59,10 +57,12 @@ describe('ACP prompt lifecycle', () => { ]) const sessionId = await newSession(harness) await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) - expect(harness.updates).toContainEqual({ + const image = harness.updates.find(update => update.sessionUpdate === 'agent_message_chunk') + expect(image).toMatchObject({ sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'AQ==', mimeType: 'image/png' }, }) + expect(image !== undefined && 'messageId' in image && typeof image.messageId === 'string').toBe(true) }) it('preserves committed text/image/text order on the ACP wire', async () => { @@ -82,11 +82,15 @@ describe('ACP prompt lifecycle', () => { await harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'show it' }] }) - expect(harness.updates).toEqual([ - { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'before' } }, - { sessionUpdate: 'agent_message_chunk', content: { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' } }, - { sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'after' } }, + expect(harness.updates.map(update => update.sessionUpdate)).toEqual([ + 'agent_message_chunk', 'agent_message_chunk', 'agent_message_chunk', ]) + expect(harness.updates.map(update => 'content' in update ? update.content : undefined)).toEqual([ + { type: 'text', text: 'before' }, + { type: 'image', data: 'Ag==', mimeType: 'image/jpeg' }, + { type: 'text', text: 'after' }, + ]) + expect(new Set(harness.updates.map(update => 'messageId' in update ? update.messageId : undefined)).size).toBe(1) }) it('does not settle a prompt before ordered output delivery drains', async () => { @@ -259,6 +263,35 @@ describe('ACP prompt lifecycle', () => { await expect(first).resolves.toEqual({ stopReason: 'cancelled' }) }) + it('routes JSON-RPC request cancellation through the prompt cancellation path', async () => { + harness = await makeBridgeHarness({ script: ['hang'] }) + const sessionId = await newSession(harness) + const controller = new AbortController() + const prompt = harness.client.prompt( + { sessionId, prompt: [{ type: 'text', text: 'one' }] }, + { cancellationSignal: controller.signal }, + ) + await vi.waitFor(() => { expect(harness!.ctx.agents.get(SessionId(sessionId))?.status).toBe('running') }) + + controller.abort() + + await expect(prompt).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.adapter.requests[0]?.signal?.aborted).toBe(true) + }) + + it('cancels a prompt request whose JSON-RPC signal is already aborted', async () => { + harness = await makeBridgeHarness({ script: [] }) + const sessionId = await newSession(harness) + const controller = new AbortController() + controller.abort() + + await expect(harness.client.prompt( + { sessionId, prompt: [{ type: 'text', text: 'never admitted' }] }, + { cancellationSignal: controller.signal }, + )).resolves.toEqual({ stopReason: 'cancelled' }) + expect(harness.adapter.requests).toEqual([]) + }) + it('reserves the prompt slot during image admission and cancels without a late followup', async () => { harness = await makeBridgeHarness({ imageCapable: true, script: [] }) const validationStarted = Promise.withResolvers() @@ -464,7 +497,9 @@ describe('ACP prompt lifecycle', () => { await expect(harness.client.prompt({ sessionId, prompt: [{ type: 'text', text: 'two' }] })) .resolves.toEqual({ stopReason: 'end_turn' }) - await vi.waitFor(() => { expect(messageText(harness!)).toBe('next') }) + // 'partial' is the cancelled turn's finalized prefix update; 'next' proves + // the second prompt settled independently of the aborted turn's late end. + await vi.waitFor(() => { expect(messageText(harness!)).toBe('partialnext') }) }) it('a retry turn adopts the prompt instead of rejecting at the failed turn end', async () => { diff --git a/packages/acp/acp/tests/updates.spec.ts b/packages/acp/acp/tests/updates.spec.ts new file mode 100644 index 0000000000..832403c6db --- /dev/null +++ b/packages/acp/acp/tests/updates.spec.ts @@ -0,0 +1,93 @@ +import { describe, expect, it, vi } from 'vitest' +import type { Context } from '@deepseek-ai/cordis' +import { ToolCallId, MessageId } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' +import { assistantUpdates, toolCallUpdate, toolResultUpdate } from '../src/updates.ts' + +/** Minimal committed assistant event for pure update projection tests. */ +function assistantEvent( + content: SessionEvent<'assistant/message'>['data']['message']['content'], + usage?: SessionEvent<'assistant/message'>['data']['usage'], +): SessionEvent<'assistant/message'> { + return { + type: 'assistant/message', + seq: 0, + time: 0, + data: { + turn: 1, + step: 1, + message: { + id: MessageId('message-1'), + role: 'assistant', + source: { kind: 'model', provider: 'mock', model: 'mock' }, + content, + }, + ...usage === undefined ? {} : { usage }, + }, + } +} + +describe('standard ACP update projection', () => { + it('omits empty reasoning, unsupported assistant blocks, and absent usage', async () => { + const ctx = { get: () => undefined } as unknown as Context + const session = { requestContext: () => undefined } as unknown as Session + const event = assistantEvent([ + { type: 'reasoning', text: '' }, + { type: 'tool-call', id: ToolCallId('call-hidden'), name: 'hidden', arguments: '{}' }, + ]) + + await expect(assistantUpdates(ctx, session, event)).resolves.toEqual([]) + }) + + it('requires both measured usage and context capacity', async () => { + const meter = { measure: vi.fn(() => ({ totalTokens: 7 })) } + const withMeter = { get: (name: string) => name === 'tokenMeter' ? meter : undefined } as unknown as Context + const withoutMeter = { get: () => undefined } as unknown as Context + const withCapacity = { requestContext: () => ({ contextWindow: 100 }) } as unknown as Session + const withoutCapacity = { requestContext: () => undefined } as unknown as Session + const event = assistantEvent([{ type: 'text', text: 'done' }], { inputTokens: 1, outputTokens: 1 }) + + expect((await assistantUpdates(withMeter, withoutCapacity, event)).map(update => update.sessionUpdate)) + .toEqual(['agent_message_chunk']) + expect((await assistantUpdates(withoutMeter, withCapacity, event)).map(update => update.sessionUpdate)) + .toEqual(['agent_message_chunk']) + expect(meter.measure).not.toHaveBeenCalled() + }) + + it('preserves malformed tool input and projects a failed result without hidden content', async () => { + const call = toolCallUpdate({ + type: 'tool/call', + seq: 0, + time: 0, + data: { turn: 1, step: 1, callId: ToolCallId('call-bad'), name: 'broken', arguments: '{' }, + }) + const result = await toolResultUpdate({ get: () => undefined } as unknown as Context, { + type: 'tool/result', + seq: 0, + time: 0, + data: { + turn: 1, + step: 1, + message: { + id: MessageId('tool-message'), + role: 'user', + source: { kind: 'tool', callId: ToolCallId('call-bad') }, + content: [{ + type: 'tool-result', + toolCallId: ToolCallId('call-bad'), + isError: true, + content: [{ type: 'reasoning', text: 'hidden' }], + }], + }, + }, + }) + + expect(call).toMatchObject({ rawInput: '{' }) + expect(result).toEqual({ + sessionUpdate: 'tool_call_update', + toolCallId: 'call-bad', + status: 'failed', + content: [], + }) + }) +}) diff --git a/packages/acp/acp/tsconfig.json b/packages/acp/acp/tsconfig.json index 93aa066a8b..71276d8ee3 100644 --- a/packages/acp/acp/tsconfig.json +++ b/packages/acp/acp/tsconfig.json @@ -23,6 +23,21 @@ { "path": "../../core/agent" }, + { + "path": "../../attachment/attachment" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../llm/token-meter" + }, + { + "path": "../../mcp/mcp-client" + }, + { + "path": "../../session/session-persistence" + }, { "path": "../../interaction/user-approval" }, diff --git a/packages/api/README.i18n.yaml b/packages/api/README.i18n.yaml index 3e97083a71..37fe0e96a9 100644 --- a/packages/api/README.i18n.yaml +++ b/packages/api/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/api/README.md -README.md: 2db5c518f75a1bba5790146a5f1b91a4fa5bf745 -README.zh.md: 1fc41ee43c184054a558e6fa8ebe7de0e87530b6 +README.md: 1d9bcc49684ee557bc62b8ba2bbe79918c5de5ca +README.zh.md: 2aae832f8dc9ece0f15283efded7c9c05b5bff88 diff --git a/packages/api/README.md b/packages/api/README.md index 2db5c518f7..1d9bcc4968 100644 --- a/packages/api/README.md +++ b/packages/api/README.md @@ -1,17 +1,56 @@ +--- +description: "Package map for the application's Remote layer: typed Client-to-Host capability calls, results, and forwarded events, for users and maintainers navigating the group." +kind: "package-group" +--- + # api/ — Remote API layers English | [中文](README.zh.md) -The application-facing Remote stack. `remotes` owns BFF policy and the selected business API, while `gateway` implements the Typert unary RPC endpoints shared by Host and Client environments. +## Summary + +The `api/` group provides the application's Remote layer: a Client environment can call the business capabilities running on the Host — manage goals, run commands, list the plugin inventory, discover file and session references — as typed method calls, and receive the results or forwarded Host events. `remotes` decides which capabilities are exposed and how each call reaches the right session's agent; `gateway` carries the calls and their results between Client and Host. The stack runs over the application's shared Connection; streaming session data is deliberately outside it. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +The packages below provide the Remote layer; the package READMEs own the exhaustive contracts. | Package | Role | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup policy and Client Remote contribution assembly | no service; configures `ctx.typert` and consumes `ctx.remote` | -| [`gateway/`](gateway/README.md) | Host Typert dispatcher and Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | +| [`remotes/`](remotes/README.md) | Chooses which Host capabilities and events the Client can consume. | — | +| [`gateway/`](gateway/README.md) | Carries typed unary calls, multiplexed streams, and forwarded Host events. | `ctx.typertGateway` / `ctx.remote` | +| [`session-controller/`](session-controller/README.md) | Owns Session commands, history streams, live control state, and Agent/Session identity policy. | `ctx.sessionController` / `ctx.remote.session` | +| [`workspace-controller/`](workspace-controller/README.md) | Owns Workspace mutations and the complete Client Workspace projection. | `ctx.workspaceController` / `ctx.remote.workspace` | -The runtime dependency direction is `remotes → gateway → connection → webserver`: the BFF consumes the shared `TypertClientRemote` contract, Gateway delegates transport to Connection, and Connection mounts on the HTTP server. Cordis service injection and Client module metadata preserve this order without importing the concrete Gateway from the Remotes Client entry. +Remote calls run Client → Host over the application's shared Connection. API Gateway owns Remote transport, while the two controller packages own Session and Workspace behavior. Endpoints without a Remote definition fall through to the application's API Proxy. -## Known Limitations and Deferred Work +----- -- Connection and WebServer remain at [`client/connection`](../client/connection/README.md) and [`host/webserver`](../host/webserver/README.md); a later package-only move can place them under `api/connection` and `api/webserver` without changing their service contracts. -- The legacy API Proxy remains at [`host/apiproxy`](../host/apiproxy/README.md) as the fallback for methods not yet migrated to Remote. It consumes the Host resolver owned by `api-remotes` so migrated and legacy methods retain one Agent/Session identity policy. + +## Related documentation + +Start with the API Gateway reference to see the Remote model end to end, then the Typert subsystem page for the shared definitions, and the carrier and fallback packages for how calls travel and how endpoints without Remote definitions are served. + +- [API Gateway reference](../../docs/api-gateway.md) — the current-state reference for the Typert API Gateway: programming model, generation pipeline, and runtime invocation. +- [Typert subsystem reference](../../docs/subsystems/typert.md) — the public contracts shared by protocol, Gateway, and consumer assemblies. +- [Connection](../client/connection/README.md) — the RPC carrier, `/api` trust fence, and response envelopes behind every Remote call. +- [API Proxy](../host/apiproxy/README.md) — the fallback for endpoints without Remote descriptors. + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/README.zh.md b/packages/api/README.zh.md index 1fc41ee43c..2aae832f8d 100644 --- a/packages/api/README.zh.md +++ b/packages/api/README.zh.md @@ -1,17 +1,56 @@ -# api/:Remote API 层 +--- +description: "应用 Remote 层的包映射:类型化的 Client 到 Host 能力调用、结果与转发事件,供用户与维护者浏览该组。" +kind: "package-group" +--- + +# api/ — Remote API 层 [English](README.md) | 中文 -面向应用的 Remote 技术栈。`remotes` 负责 BFF 策略和选定的业务 API,`gateway` 则实现 Host 与 Client 环境共用的 Typert 一元 RPC endpoint。 +## 概述 + +`api/` 组提供应用的 Remote 层:Client 环境可以调用运行在 Host 上的业务能力——管理目标、运行命令、查看插件清单、发现文件与会话引用——调用方式是类型化方法,并接收结果或转发的 Host 事件。`remotes` 决定暴露哪些能力、以及每次调用如何到达正确会话的 agent;`gateway` 在 Client 与 Host 之间承载调用及其结果。技术栈运行在应用共享的 Connection 之上;流式会话数据刻意不在其中。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +下面两个包共同提供 Remote 层;穷尽式约定以各包 README 为准。 | 包 | 职责 | ctx key | |---|---|---| -| [`remotes/`](remotes/README.md) | Host Agent/Session lookup 策略与 Client Remote contribution 装配 | 无服务;配置 `ctx.typert` 并消费 `ctx.remote` | -| [`gateway/`](gateway/README.md) | Host Typert 分发器与 Client Remote endpoint | `ctx.typertGateway` / `ctx.remote` | +| [`remotes/`](remotes/README.zh.md) | 决定 Client 可以消费哪些 Host 能力与事件。 | — | +| [`gateway/`](gateway/README.zh.md) | 承载带类型的单次调用、多路复用 stream 与转发的 Host 事件。 | `ctx.typertGateway` / `ctx.remote` | +| [`session-controller/`](session-controller/README.zh.md) | 拥有 Session 命令、历史 stream、实时控制状态与 Agent/Session 身份策略。 | `ctx.sessionController` / `ctx.remote.session` | +| [`workspace-controller/`](workspace-controller/README.zh.md) | 拥有 Workspace 变更与完整 Client Workspace 投影。 | `ctx.workspaceController` / `ctx.remote.workspace` | -运行时依赖方向为 `remotes → gateway → connection → webserver`:BFF 消费共享的 `TypertClientRemote` 约定,Gateway 把传输交给 Connection,Connection 再挂载到 HTTP server。Cordis 服务注入与 Client 模块元数据在不让 Remotes Client 入口导入具体 Gateway 实现的前提下维持该顺序。 +Remote 调用沿 Client → Host 方向运行在应用共享的 Connection 之上。API Gateway 拥有 Remote 传输,两个 controller 包分别拥有 Session 与 Workspace 行为。没有 Remote 定义的 endpoint 会回退到应用的 API Proxy。 -## 已知限制与延期工作 +----- -- Connection 与 WebServer 仍位于 [`client/connection`](../client/connection/README.md) 和 [`host/webserver`](../host/webserver/README.md);后续可以只移动包,将它们放到 `api/connection` 和 `api/webserver` 下,而无需改变服务约定。 -- 旧 API Proxy 仍位于 [`host/apiproxy`](../host/apiproxy/README.md),作为尚未迁移到 Remote 的方法的回退路径。它使用由 `api-remotes` 持有的 Host resolver,使已迁移与旧方法共用同一套 Agent/Session 身份策略。 + +## 相关文档 + +先读 API Gateway 参考以端到端了解 Remote 模型,再读 Typert 子系统页了解共享定义,以及载体与回退包了解调用如何传输、没有 Remote 定义的 endpoint 如何被服务。 + +- [API Gateway 参考](../../docs/api-gateway.zh.md)——Typert API Gateway 的现状参考:编程模型、生成流水线与运行时调用。 +- [Typert 子系统参考](../../docs/subsystems/typert.zh.md)——protocol、Gateway 与消费方装配共享的公共约定。 +- [Connection](../client/connection/README.zh.md)——每次 Remote 调用背后的 RPC 载体、`/api` 信任围栏与响应封装。 +- [API Proxy](../host/apiproxy/README.zh.md)——没有 Remote 描述符的 endpoint 的回退路径。 + + +## 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/gateway/README.i18n.yaml b/packages/api/gateway/README.i18n.yaml index 826bd88678..9bc399b963 100644 --- a/packages/api/gateway/README.i18n.yaml +++ b/packages/api/gateway/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/api/gateway/README.md -README.md: 7caf707c376bd3e2654fad1f0c01ac83e6faa44c -README.zh.md: b340df982ed1ca9b980fbcf60729d8124319ce6e +README.md: 2e0cb32e4db6c1bee8576a5addd5492fb7ef53ac +README.zh.md: f67e13f6b1f789da02796397a121e59a55427cca diff --git a/packages/api/gateway/README.md b/packages/api/gateway/README.md index 7caf707c37..2e0cb32e4d 100644 --- a/packages/api/gateway/README.md +++ b/packages/api/gateway/README.md @@ -1,29 +1,55 @@ +--- +description: "Typed Client-to-Host calls and streams: dispatch, validation, cancellation, reconnection, and forwarded Host events." +kind: "package-reference" +--- + # @deepseek-ai/dsh-api-gateway English | [中文](README.zh.md) -Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes and transport, request correlation, trust, and response envelopes to Connection. +## Summary +Two-sided Typert RPC endpoint for Host and Client Cordis environments. The Host entry provides `ctx.typertGateway`, while `@deepseek-ai/dsh-api-gateway/client` provides `ctx.remote`; both consume the same generated `InvocationDescriptor` contract and leave business selection to API Remotes. Connection carries unary request correlation, trust, and response envelopes, while Gateway owns multiplexed Remote streams. + +## Table of Contents + +- [Host service: `TypertGatewayService` (ctx key: `typertGateway`)](#host-service-typertgatewayservice-ctx-key-typertgateway) +- [Client service: `ClientRemote` (ctx key: `remote`)](#client-service-clientremote-ctx-key-remote) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + ## Host service: `TypertGatewayService` (ctx key: `typertGateway`) `ctx.typertGateway.invoke()` resolves the current descriptor and Cordis Service for each call, validates exact named arguments, resolves registered object or Context identities, invokes the public business method, and validates its result. Business Services extend `TypertRemoteService` and mark methods with `@Remote` or `@RemoteScope` from [`dsh-typert-protocol`](../../typert/protocol/README.md); `bindTypertRemote()` remains available when another base class owns inheritance. -Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context provider. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. +Strict mode reads generated invocation descriptors from `ctx.typert.local`. Lookup parameters use the currently active resolver in `ctx.typert.lookups`: the business package registers the stable declaration and default policy, while Host composition can override resolution behavior with effect-scoped `configure()`; `@RemoteScope` resolves its receiver through a registered Host Context adapter. SRC mode is a development fallback for endpoints that have never had a strict definition; it parses simple parameter names and accepts only JSON-safe values for non-lookup parameters. Withdrawing an observed strict definition fails instead of weakening validation. The Host entry registers a trusted-host interceptor on Connection's shared `/api` FetchHandler. Connection passes this composite handler through its HTTP bridge; the handler dispatches claimed endpoints to Gateway and unclaimed endpoints to API Proxy. Direct `invoke()` calls preserve business errors; `TypertGatewayError` distinguishes failures owned by dispatch, binding, providers, lookup, Context, arguments, and codecs. A resolver may use `TypertLookupFailure` to carry an existing RPC error, preserving its original error code for policy rejections such as cold-resume failures or ownership fences. A cancellation-aware Remote method declares `signal: AbortSignal` as its final Host parameter. The signal is descriptor metadata rather than a wire argument: Connection supplies it to the Gateway, and the Gateway injects it after decoded business parameters. SRC recognizes the reserved final name, while strict generation additionally requires the global `AbortSignal` type. +A stream Remote uses `@Remote({ mode: 'stream' })` and returns an `Iterable` or `AsyncIterable`. `ctx.typertGateway.stream()` applies the same endpoint, argument, lookup, and cancellation checks as unary invocation, then validates each yielded item with the generated result codec. The Client opens the Gateway-owned `/api/remote.mux` WebSocket when its plugin activates, keeps it connected while idle, and retries physical connection failures with capped backoff. Independently cancellable logical streams share that socket; an in-process Connection carrier provides equivalent streams directly without opening it. + +Host composition can register one application event source through `registerRemoteEvents()`. Gateway reserves the internal `$events` logical endpoint for that source, accepts only empty `args`, and aborts streams opened by the registration when the source is withdrawn. API Remotes owns the event selection, argument validation, and per-Client queues. Its source factory attaches incremental listeners synchronously; Gateway then yields `{ type: 'ready' }` before iterating the source, so the Client starts baseline reads only after incremental delivery is ready. + + ## Client service: `ClientRemote` (ctx key: `remote`) `ctx.remote.$mount()` validates and registers a generated Host-for-Client contribution, then installs concrete direct and scoped methods for the calling Cordis fiber. Each namespace is a traced `remote.` child Service and unloads after its last method is withdrawn. Duplicate endpoints, namespace collisions, and descriptors without strict generated codecs fail before methods become callable. -Each call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before calling Connection. The returned value is validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls, and makes retained method handles reject. +Each unary call validates positional inputs, constructs the descriptor's exact named `args`, and sends it through `ctx.connection.rpc.call('/api', endpoint, ...)`. A generated stream method returns an `AsyncIterable` and opens one logical stream through an in-process Connection carrier when available, otherwise through the shared Gateway WebSocket. Generated cancellation-aware methods accept a final optional `AbortSignal`; the Client combines it with the contribution mount lifetime before invoking the carrier. Unary results and every stream item are validated before reaching application code. Withdrawing a contribution removes its descriptors and methods together, aborts in-flight calls and streams, and makes retained method handles reject. -`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. Delivery is one-way and follows registration order; a listener that throws is logged and isolated from the remaining listeners, which never affects the frame pump. `ctx.remote.$dispatch()` is the other half of that surface, and it is the carrier's: the Client half owning the Host frame sink hands each decoded frame over, and an event name nobody subscribes to is dropped, since the wire carries whatever the Host selected. A consumer subscribes and never calls it. +`ctx.remote.$stream()` returns a single-consumer `RemoteStream` spanning physical carrier generations. It permits one immediate retry while the Host remains available, otherwise waits for the next connected Host generation, and annotates each item with its physical generation. The domain consumer validates and accepts each generation's opening value; business and protocol failures remain terminal. `RemoteSnapshotStream` adds one opening snapshot followed by deltas. `RemoteJournalStream` adds follow-before-page opening, pagination, reconnect catch-up, and gap repair over domain-defined inclusive entry ranges; it removes complete duplicates and rejects gaps, inverted ranges, and partial overlaps. Disposing any stream cancels its requests and resolves after the active iterator is fully stopped. + +`ctx.remote.$on()` subscribes to one forwarded Host event. Its legal keys are exactly the Host assembly's forwarding selection, and the listener type is the owning package's own Cordis `Events` declaration, so no second signature can drift from it. Each subscription belongs to the calling fiber and disappears with it. The Client Remote service registers the `$events` pump as a Connection generation source when it activates, whether any `$on` listener exists. Browsers use Remote mux, while in-process compositions use `connection.rpc.open`; the `ready` item and `host.describe` jointly establish a Connection generation. Carrier failure, Remote stream failure, unexpected normal completion, a non-ready opening item, or a malformed event item ends that generation and lets Connection reopen it after backoff. Ordinary notifications run in registration order and isolate listener failures. Agent-scoped waterfalls let a listener return a result, call `next()`, or reject; Gateway returns that outcome through the existing HTTP unary carrier. Generated declaration merges provide the TypeScript API through the shared `TypertClientRemote` contract. The Client entry contains no Host Service or Host Cordis interface merge, and method lookup and invocation use ordinary objects and functions rather than a JavaScript Proxy. + ## Model Experience None, as the package dispatches application calls and registers no prompt, tool, or session event. @@ -34,9 +60,22 @@ No direct effect; invoked business Services own any model-visible result. ## Known Limitations and Deferred Work + + - The Connection adapter maps ordinary dispatch failures and business exceptions to the RPC `internal` code with empty details; lookup-policy errors carried by `TypertLookupFailure` are returned unchanged. Structured `TypertGatewayError` categories remain available only to same-process callers. - SRC mode supports unique identifier parameters without destructuring, defaults, or rest parameters. It validates JSON safety rather than generated business types and never infers optional fields. - Only strict generated contributions can mount on the Client face. SRC markers have no Client codec or type projection. -- The package dispatches unary methods only. Incremental Session data uses a separate named-stream protocol over the same Connection. +- `$stream()` supervises carrier replacement but does not infer replay semantics; each domain owns its resume cursor or replacement-baseline validation and normal-end classification. Connection generations reopen the internal `$events` stream; one-way notifications are not replayed, while pending scoped waterfalls retain their event id across replay. - Lookup resolvers are configured per key; an individual Remote parameter or endpoint cannot currently select a live-only policy under the same `agent`/`session` key. -- Forwarded events reach `$on` exactly as the Host emitted them: no payload projection or redaction, no Scope-bound subscription, and no replay after a reconnect. +- Forwarded events reach `$on` without business-payload projection or redaction. Ordinary notifications are not replayed after reconnect; Agent-scoped waterfalls project only the top-level Agent identity needed to select the Client Context and carry their own pending lifetime. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/gateway/README.zh.md b/packages/api/gateway/README.zh.md index b340df982e..f67e13f6b1 100644 --- a/packages/api/gateway/README.zh.md +++ b/packages/api/gateway/README.zh.md @@ -1,29 +1,55 @@ +--- +description: "带类型的 Client 到 Host 调用与 stream:分派、校验、取消、重连与转发的 Host 事件。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-api-gateway [English](README.md) | 中文 -为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes,将传输、请求关联、信任和响应封装交给 Connection。 +## 概述 +为 Host 与 Client 两侧的 Cordis 环境提供 Typert RPC endpoint。Host 入口提供 `ctx.typertGateway`,`@deepseek-ai/dsh-api-gateway/client` 则提供 `ctx.remote`;两者使用同一份生成的 `InvocationDescriptor` 约定,并将业务选择交给 API Remotes。Connection 承载一元调用的请求关联、信任和响应 envelope,Gateway 则拥有多路复用的 Remote 流。 + +## 目录 + +- [Host 服务:`TypertGatewayService`(ctx key:`typertGateway`)](#host-service-typertgatewayservice-ctx-key-typertgateway) +- [Client 服务:`ClientRemote`(ctx key:`remote`)](#client-service-clientremote-ctx-key-remote) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + ## Host 服务:`TypertGatewayService`(ctx key:`typertGateway`) -每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 +每次调用时,`ctx.typertGateway.invoke()` 都会解析当前的描述符和 Cordis 服务,校验具名参数是否完全匹配,解析已注册的对象或 Context 身份标识,调用公开的业务方法,并校验其结果。业务服务继承 [`dsh-typert-protocol`](../../typert/protocol/README.zh.md) 的 `TypertRemoteService`,并用 `@Remote` 或 `@RemoteScope` 标记方法;已有其他基类时仍可改用 `bindTypertRemote()`。 -严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context 提供方解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 +严格模式从 `ctx.typert.local` 读取生成的调用描述符。查找参数使用 `ctx.typert.lookups` 中当前有效的 resolver:业务包注册稳定声明与默认策略,Host 组合可用 effect-scoped `configure()` 覆盖解析行为;`@RemoteScope` 则通过已注册的 Host Context adapter 解析其接收者。SRC 模式是开发阶段的回退路径,适用于从未具备严格定义的端点;它解析简单参数名,并且只允许非查找参数使用可安全表示为 JSON 的值。已观测到的严格定义一旦撤回,系统会直接报错,而不会降低校验强度。 Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandler 上注册 trusted-host interceptor。Connection 把这个复合 handler 交给 HTTP bridge;handler 将已认领 endpoint 分发给 Gateway,未认领 endpoint 则交给 API Proxy。直接调用 `invoke()` 会保留业务错误;`TypertGatewayError` 可区分分发、绑定、提供方、查找、Context、参数和编解码器各自负责的故障。resolver 可以用 `TypertLookupFailure` 携带既有 RPC error,使冷恢复失败或 ownership fence 等策略拒绝保持原错误码。 支持取消的 Remote 方法会把 `signal: AbortSignal` 声明为最后一个 Host 参数。signal 是 descriptor 元数据,而不是 wire 参数:Connection 将它提供给 Gateway,Gateway 则在已解码的业务参数之后注入它。SRC 识别这个保留的末位参数名,严格生成还要求它具有全局 `AbortSignal` 类型。 +流式 Remote 使用 `@Remote({ mode: 'stream' })` 并返回 `Iterable` 或 `AsyncIterable`。`ctx.typertGateway.stream()` 执行与一元调用相同的 endpoint、参数、lookup 和取消校验,再用生成的 result codec 校验每个产出项。Client 插件激活时打开 Gateway 自有的 `/api/remote.mux` WebSocket,使其在空闲时保持连接,并以有上限的退避重试物理连接失败。可独立取消的逻辑流共享这条连接;进程内 Connection 载体直接提供等价的流,不打开该 WebSocket。 + +Host 组合可通过 `registerRemoteEvents()` 注册唯一的应用事件 source。Gateway 为它保留内部 `$events` logical endpoint,只接受空 `args`,并在 source 撤回时中止该注册打开的 stream。事件名单、参数校验和每 Client 队列由 API Remotes 拥有。source factory 在返回 iterable 前同步挂好增量 listener;Gateway 随后先产出 `{ type: 'ready' }`,再迭代 source,让 Client 只在增量投递就绪后开始 baseline 读取。 + + ## Client 服务:`ClientRemote`(ctx key:`remote`) `ctx.remote.$mount()` 会校验并注册生成的 Host-for-Client 贡献项,然后为发起调用的 Cordis fiber 安装具体的直接方法和作用域方法。每个 namespace 都是可追踪的 `remote.` 子 Service,并在最后一个方法撤回后卸载。重复端点、命名空间冲突,以及缺少生成的严格编解码器的描述符,都会在方法可调用前报错。 -每次调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用 Connection 前将它与贡献项的挂载生命周期合并。返回值经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用,并使外部仍持有的方法句柄在调用时返回拒绝。 +每次一元调用都会校验位置参数,构造与描述符完全匹配的具名 `args`,再通过 `ctx.connection.rpc.call('/api', endpoint, ...)` 发送。生成的流方法返回 `AsyncIterable`,并在进程内 Connection 载体可用时通过它打开逻辑流,否则通过共享的 Gateway WebSocket 打开。生成的支持取消的方法接受最后一个可选 `AbortSignal`;Client 会在调用载体前将它与贡献项的挂载生命周期合并。一元结果和每个流项都经过校验后才会交给应用代码。撤回贡献项会同时移除其描述符和方法、中止正在进行的调用与流,并使外部仍持有的方法句柄在调用时返回拒绝。 -`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。投递是单向的,并按注册顺序进行;抛错的 listener 会被记录并与其余 listener 隔离,绝不影响帧泵。`ctx.remote.$dispatch()` 是该面的另一半,且属于载体:持有 Host 帧 sink 的 Client 半把每个解码后的帧交进来,收到无人订阅的事件名即丢弃,因为 wire 上出现什么取决于 Host 的转发选择。消费方只订阅,绝不调用它。 +`ctx.remote.$stream()` 返回跨越多个物理载体代次的单消费方 `RemoteStream`。Host 仍在线时,它允许一次立即重试;Host 离线时,它等待下一代连接,并为每个流项标注物理代次。领域消费方校验并接受各代次的 opening value;业务与协议错误仍然终止流。`RemoteSnapshotStream` 在此之上规定每代由一个 opening snapshot 和后续 delta 组成。`RemoteJournalStream` 基于领域提供的 entry 闭区间提供 follow-before-page、分页、重连追赶与缺口修复;它丢弃完整重复项,并拒绝缺口、倒置区间和部分重叠。dispose 任一种 stream 都会取消其请求,并在活动 iterator 完全停止后完成。 + +`ctx.remote.$on()` 订阅一条被转发的 Host 事件。它的合法键恰好等于 Host 装配声明的转发选择,listener 类型就是事件所属包自己的 Cordis `Events` 声明,因此不存在会与之漂移的第二份签名。每个订阅归属发起调用的 fiber,并随该 fiber 一起消失。Client Remote 服务激活时就把 `$events` pump 注册为 Connection generation source,因此即使当前无 `$on` 订阅,它也会在 Connection 循环启动时打开。浏览器使用 Remote mux,进程内组合使用 `connection.rpc.open`;`ready` 项与 `host.describe` 共同建立一个 Connection generation。物理 carrier 失败、Remote stream error、意外正常结束、非 ready 首项或畸形事件项都会终止该 generation,由 Connection 退避后重开。普通通知按注册顺序运行并隔离 listener 失败;Agent-scoped waterfall 允许 listener 返回结果、调用 `next()` 或拒绝,Gateway 再通过现有 HTTP 一元载体回送该结果。 生成的声明合并通过共享的 `TypertClientRemote` 约定提供 TypeScript API。Client 入口不包含 Host 服务或 Host Cordis 接口合并;方法查找和调用使用普通对象与函数,而不使用 JavaScript Proxy。 + ## 模型体验 无,因为该包分发应用调用,不注册任何提示词、工具或会话事件。 @@ -34,9 +60,22 @@ Connection 可用时,Host 入口会在 Connection 共享的 `/api` FetchHandle ## 已知限制与延期工作 + + - Connection 适配器将普通分发故障和业务异常映射为 RPC 的 `internal` 代码,且不附带详细信息;`TypertLookupFailure` 携带的 lookup 策略错误会原样返回。结构化的 `TypertGatewayError` 类别仅供同进程调用方使用。 - SRC 模式仅支持名称唯一的标识符参数,不支持解构、默认值或剩余参数。它只校验值能否安全表示为 JSON,不校验生成的业务类型,也绝不会推断可选字段。 - Client 侧只能挂载严格模式生成的贡献项。SRC 标记不具备 Client 编解码器或类型投影。 -- 该包只分发一元方法。增量会话数据通过同一个 Connection 上独立的具名流协议传输。 +- `$stream()` 监督载体替换,但不推断回放语义;各领域自行拥有恢复 cursor 或替换 baseline 的校验,以及正常结束的分类。Connection generation 会重开内部 `$events`;单向通知不会重放,仍处于 pending 的 scoped waterfall 则沿用同一个 event id 重放。 - lookup resolver 按 key 配置;当前无法让单个 Remote 参数或 endpoint 在同一 `agent`/`session` key 下选择 live-only 策略。 -- 被转发的事件原样到达 `$on`:没有载荷投影或脱敏,不支持 Scope 化订阅,重连后也不重放。 +- 被转发的事件到达 `$on` 时不做业务载荷投影或脱敏。普通通知在重连后不重放;Agent-scoped waterfall 只投影选择 Client Context 所需的顶层 Agent 身份,并自行携带 pending 生命周期。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 99a489fc3c..e74d19946f 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "Typert Remote Host dispatcher and Client API endpoint", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -56,19 +56,25 @@ ], "license": "MIT", "dependencies": { - "@deepseek-ai/dsh-typert-protocol": "workspace:^" + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "ws": "^8.21.0" }, "peerDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index c4b087fd5d..4fe46e6d2d 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -5,10 +5,13 @@ */ import { Service } from '@deepseek-ai/cordis' -import type { Context, Events } from '@deepseek-ai/cordis' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import type { Context } from '@deepseek-ai/cordis' +import type { + ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, + TypertClientEventListener, TypertClientRemote, RemoteResult, TypertCodec, @@ -16,6 +19,29 @@ import type { TypertRemoteContribution, TypertRemoteEvent, } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteStreamCarrierError, + RemoteStreamError, + RemoteStreamMuxClient, +} from './stream-client.ts' +import { ClientRemoteEvents } from './remote-events.ts' +import { + RemoteStream, + type RemoteStreamOptions, +} from './remote-stream.ts' + +export { RemoteStreamCarrierError, RemoteStreamError } from './stream-client.ts' +export { RemoteJournalStream } from './journal-stream.ts' +export type { + RemoteJournalChange, + RemoteJournalFrame, + RemoteJournalStreamOptions, + RemoteStreamFactory, +} from './journal-stream.ts' +export { RemoteStream } from './remote-stream.ts' +export type { RemoteStreamItem, RemoteStreamOptions } from './remote-stream.ts' +export { RemoteSnapshotStream } from './snapshot-stream.ts' +export type { RemoteSnapshotStreamOptions } from './snapshot-stream.ts' interface MountToken { active: boolean @@ -47,13 +73,38 @@ interface BoundContextIdentity { readonly value: unknown } +interface PreparedClientInvocation { + readonly endpoint: string + readonly args: Readonly> + readonly signal: AbortSignal +} + interface RemoteNamespaceHandle { readonly service: RemoteNamespaceService readonly dispose: TypertDisposer } -/** Typed Remote service augmented by generated direct namespaces. */ -export type ClientRemote = TypertClientRemote +interface LoaderReadiness { + await(): Promise +} + +/** One descriptor's mounted variants, for the group disposer to unwind. */ +interface InstalledMethod { + readonly descriptor: InvocationDescriptor + readonly token: MountToken + direct: boolean + scoped: boolean +} + +/** Typed Remote service augmented by generated direct namespaces and Gateway stream supervision. */ +export interface ClientRemote extends TypertClientRemote { + /** + * Create one independently cancellable, reconnecting logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a single-consumer stream annotated with physical generation ids. + */ + $stream(options: RemoteStreamOptions): RemoteStream +} declare module '@deepseek-ai/cordis' { interface Context { @@ -73,28 +124,46 @@ export function apply(ctx: Context): void { new ClientRemoteService(ctx) } -/** One subscribed listener after `$on` erased its per-event argument list. */ -type RemoteEventListener = (...args: never[]) => void - -/** - * One subscription, identified by the registration rather than by its listener: - * two fibers may subscribe the same function object to the same event, and each - * disposer must retire only its own registration. - */ -interface RemoteEventSubscription { - readonly listener: RemoteEventListener -} - -class ClientRemoteService extends Service implements TypertClientRemote { +class ClientRemoteService extends Service implements ClientRemote { private readonly ownerCtx: Context + private readonly connection: ConnectionHandle private readonly namespaces = new Map() - private readonly subscriptions = new Map() + private readonly streams = new RemoteStreamMuxClient() + private readonly events: ClientRemoteEvents private mutations = Promise.resolve() constructor(ctx: Context) { super(ctx, 'remote') this.ownerCtx = ctx - ctx.effect(() => () => { this.subscriptions.clear() }, 'api-gateway.client.subscriptions') + const connection = ctx.get('connection') as ConnectionHandle + this.connection = connection + this.events = new ClientRemoteEvents( + ctx, + connection, + (endpoint, payload, signal) => this.openRemoteStream(endpoint, payload, signal), + ) + if (connection.rpc.open === undefined) this.streams.start() + let disposed = false + let loop: ReturnType | undefined + const start = (): void => { + if (disposed) return + loop = connection.start({ + onConnected: () => { this.ownerCtx.emit('connection/reset') }, + }) + } + const loader = ctx.get('loader') as LoaderReadiness | undefined + if (loader === undefined) start() + else void loader.await().then(start, () => {}) + ctx.effect(() => async () => { + disposed = true + loop?.stop() + await this.events.dispose() + await this.streams.close() + }, 'api-gateway.client.transport') + } + + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(this.connection, options) } async $mount(contribution: TypertRemoteContribution): ReturnType { @@ -109,60 +178,24 @@ class ClientRemoteService extends Service implements TypertClientRemote { $on( event: Event, - listener: Events[Event], - ): ReturnType { - // The table is keyed by the runtime event name, so the argument list this - // signature pins per event cannot survive in it; `$deliver` restores it - // from the frame the Host emitted for that same name. - const subscription: RemoteEventSubscription = { listener } - const owned = this.ctx.effect(() => { - const listeners = this.listeners(event) - listeners.push(subscription) - return () => { - const at = listeners.indexOf(subscription) - /* v8 ignore next -- listener */ - if (at >= 0) listeners.splice(at, 1) - } - }, `api-gateway.client.$on(${JSON.stringify(event)})`) - return () => { void owned() } + listener: TypertClientEventListener, + ): () => void { + return this.events.subscribe(this.ctx, event, listener) } - /** - * Deliver one forwarded event in registration order, isolating a listener - * that fails either synchronously or by rejecting a returned promise; see - * {@link TypertClientRemote.$dispatch} for the caller contract. - */ - $dispatch(event: string, args: readonly unknown[]): void { - const listeners = this.subscriptions.get(event) - if (listeners === undefined) return - // Snapshot: a listener may subscribe or dispose during delivery, and this - // round's recipients are the ones registered when the frame arrived. - for (const { listener } of [...listeners]) { - const report = (error: unknown): void => { - console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) - } - try { - /* oxlint-disable-next-line typescript/no-confusing-void-expression -- - * The declared return is void, so nobody awaits an async listener; the - * runtime value is still a promise, and reading it is the only way to - * keep its rejection inside this containment instead of surfacing as an - * unhandled one. */ - const settled: unknown = listener(...args as never[]) - if (settled instanceof Promise) settled.catch(report) - } catch (error) { - report(error) - } - } - } - - /** Subscriptions for one event name; empty arrays are retained, bounded by the Host's selection. */ - private listeners(event: string): RemoteEventSubscription[] { - let listeners = this.subscriptions.get(event) - if (listeners === undefined) { - listeners = [] - this.subscriptions.set(event, listeners) - } - return listeners + /** Open one Remote stream and normalize a worker-local carrier's structural failures. */ + private openRemoteStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + noConnection = `client api: ${endpoint} has no active Connection`, + ): AsyncIterable { + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(noConnection) + const local = connection.rpc.open?.('/api', endpoint, payload, signal) + return local === undefined + ? this.streams.open(endpoint, payload, signal) + : normalizeConnectionStream(local) } private enqueue(operation: () => T | Promise): Promise { @@ -177,9 +210,17 @@ class ClientRemoteService extends Service implements TypertClientRemote { ): Promise { this.validateContribution(contribution) const disposeRemote = callerCtx.typert.remotes.register(contribution) + const groups = new Map() + for (const descriptor of contribution.descriptors) { + const group = groups.get(descriptor.namespace) + if (group === undefined) groups.set(descriptor.namespace, [descriptor]) + else group.push(descriptor) + } const installed: TypertDisposer[] = [] try { - for (const descriptor of contribution.descriptors) installed.push(await this.install(descriptor)) + for (const [namespace, descriptors] of groups) { + installed.push(await this.installNamespace(namespace, descriptors)) + } } catch (error) { for (const dispose of installed.reverse()) await dispose() await disposeRemote() @@ -235,66 +276,47 @@ class ClientRemoteService extends Service implements TypertClientRemote { } } - private async install(descriptor: InvocationDescriptor): Promise { - const token: MountToken = { active: true, abort: new AbortController() } - const installed: TypertDisposer[] = [] - try { - if (descriptor.invocation.kind === 'direct') { - installed.push(await this.installDirect(descriptor, token)) - } - const projection = scopedProjection(descriptor) - if (projection !== undefined) installed.push(await this.installScoped(descriptor, projection, token)) - } catch (error) { - token.active = false - token.abort.abort() - for (const dispose of installed.reverse()) await dispose() - throw error - } - return async () => { - /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ - if (!token.active) return - token.active = false - token.abort.abort() - for (const dispose of installed.reverse()) await dispose() - } - } - - private async installDirect(descriptor: InvocationDescriptor, token: MountToken): Promise { - const namespace = await this.namespace(descriptor.namespace) - try { - namespace.service.installDirect(descriptor, token) - } catch (error) { - await this.disposeNamespace(descriptor.namespace, namespace) - throw error - } - return async () => { - namespace.service.remove('direct', descriptor.method, token) - await this.disposeNamespace(descriptor.namespace, namespace) - } - } - - private async installScoped( - descriptor: InvocationDescriptor, - projection: ScopedProjection, - token: MountToken, + /** + * Mount one namespace's descriptor group with no visibility gap: a fresh + * namespace installs its whole group synchronously inside its fiber's + * apply, so a plugin parked on the namespace service never observes it + * without the methods the same contribution carries; an existing namespace + * takes the group in one synchronous step. + * @param name - Remote namespace. + * @param descriptors - Every contribution descriptor naming that namespace. + * @returns disposer unmounting the group and the namespace once empty. + */ + private async installNamespace( + name: string, + descriptors: readonly InvocationDescriptor[], ): Promise { - const namespace = await this.namespace(descriptor.namespace) - try { - namespace.service.installScoped(descriptor, projection, token) - } catch (error) { - await this.disposeNamespace(descriptor.namespace, namespace) - throw error + let namespace = this.namespaces.get(name) + let installed: InstalledMethod[] + if (namespace === undefined) { + ({ namespace, installed } = await this.createNamespace(name, descriptors)) + } else { + installed = installMethods(namespace.service, descriptors) } + const handle = namespace return async () => { - namespace.service.remove('scoped', descriptor.method, token) - await this.disposeNamespace(descriptor.namespace, namespace) + for (const method of [...installed].reverse()) { + /* v8 ignore next -- Cordis effect disposers are idempotent and invoke this cleanup at most once. */ + if (!method.token.active) continue + method.token.active = false + method.token.abort.abort() + if (method.scoped) handle.service.remove('scoped', method.descriptor.method, method.token) + if (method.direct) handle.service.remove('direct', method.descriptor.method, method.token) + } + await this.disposeNamespace(name, handle) } } - private async namespace(name: string): Promise { - let namespace = this.namespaces.get(name) - if (namespace !== undefined) return namespace + private async createNamespace( + name: string, + descriptors: readonly InvocationDescriptor[], + ): Promise<{ namespace: RemoteNamespaceHandle; installed: InstalledMethod[] }> { let service: RemoteNamespaceService | undefined + let installed: InstalledMethod[] | undefined const fiber = this.ownerCtx.plugin({ name: remoteServiceKey(name), apply: (ctx: Context) => { @@ -303,6 +325,9 @@ class ClientRemoteService extends Service implements TypertClientRemote { name, (direct, scoped, caller, args) => this.invokeMethod(direct, scoped, caller, args), ) + // Same synchronous window as the service registration: a dependent the + // new service unparks runs only after the methods exist. + installed = installMethods(service, descriptors) }, }) try { @@ -311,11 +336,13 @@ class ClientRemoteService extends Service implements TypertClientRemote { await fiber.dispose() throw error } - /* v8 ignore next -- a settled namespace fiber synchronously constructs its Service. */ - if (service === undefined) throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`) - namespace = { service, dispose: fiber.dispose } + /* v8 ignore next 3 -- a settled namespace fiber synchronously constructs its Service and installs the group. */ + if (service === undefined || installed === undefined) { + throw new Error(`client api: namespace ${JSON.stringify(name)} did not start`) + } + const namespace = { service, dispose: fiber.dispose } this.namespaces.set(name, namespace) - return namespace + return { namespace, installed } } private async disposeNamespace(name: string, namespace: RemoteNamespaceHandle): Promise { @@ -329,12 +356,12 @@ class ClientRemoteService extends Service implements TypertClientRemote { scoped: ScopedMethod | undefined, callerCtx: Context, values: readonly unknown[], - ): Promise> { + ): Promise> | AsyncIterable { if (scoped !== undefined) { - const binder = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) - const identity = binder?.identity(callerCtx) + const adapter = this.ownerCtx.typert.contexts.getClient(scoped.projection.context) + const identity = adapter?.identity(callerCtx) if (identity !== undefined) { - return this.invoke( + return this.invokeSelected( scoped.descriptor, scoped.projection, scoped.token, @@ -345,14 +372,28 @@ class ClientRemoteService extends Service implements TypertClientRemote { } } if (direct !== undefined) { - return this.invoke(direct.descriptor, undefined, direct.token, callerCtx, values) + return this.invokeSelected(direct.descriptor, undefined, direct.token, callerCtx, values) } if (scoped !== undefined) { - return this.invoke(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) + return this.invokeSelected(scoped.descriptor, scoped.projection, scoped.token, callerCtx, values) } throw new Error('client api: Remote method is no longer mounted') } + private invokeSelected( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): Promise> | AsyncIterable { + if (descriptor.mode === 'stream') { + return this.invokeStream(descriptor, projection, token, callerCtx, values, boundIdentity) + } + return this.invoke(descriptor, projection, token, callerCtx, values, boundIdentity) + } + private async invoke( descriptor: InvocationDescriptor, projection: ScopedProjection | undefined, @@ -363,6 +404,48 @@ class ClientRemoteService extends Service implements TypertClientRemote { ): Promise> { const endpoint = endpointOf(descriptor) if (!token.active) return withdrawn(endpoint) + const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) + const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) + try { + const result = await connection.rpc.call('/api', endpoint, { args: prepared.args }, prepared.signal) + if (!mountActive(token)) return withdrawn(endpoint) + if (!result.ok) return { ok: false, error: result.error } + return { ok: true, value: result.value } + } catch (error) { + // Carrier throws (offline or abort) are outcomes of the call, not assembly + // faults, so they join the same error branch. + return carrierFailure(endpoint, error) + } + } + + private async *invokeStream( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): AsyncGenerator { + const endpoint = endpointOf(descriptor) + if (!token.active) throw new Error(withdrawn(endpoint).error.message) + const prepared = this.prepareInvocation(descriptor, projection, token, callerCtx, values, boundIdentity) + const stream = this.openRemoteStream(endpoint, { args: prepared.args }, prepared.signal) + for await (const value of stream) { + if (!mountActive(token)) throw new Error(withdrawn(endpoint).error.message) + yield value + } + } + + private prepareInvocation( + descriptor: InvocationDescriptor, + projection: ScopedProjection | undefined, + token: MountToken, + callerCtx: Context, + values: readonly unknown[], + boundIdentity?: BoundContextIdentity, + ): PreparedClientInvocation { + const endpoint = endpointOf(descriptor) const expected = descriptor.parameters.length - (projection?.parameterIndex === undefined ? 0 : 1) const hasCallerSignal = descriptor.cancellation !== undefined && values.length === expected + 1 if (values.length !== expected && !hasCallerSignal) { @@ -375,43 +458,32 @@ class ClientRemoteService extends Service implements TypertClientRemote { } const args = Object.create(null) as Record if (projection !== undefined) { - const binder = boundIdentity === undefined + const adapter = boundIdentity === undefined ? this.ownerCtx.typert.contexts.getClient(projection.context) : undefined - if (boundIdentity === undefined && binder === undefined) { - throw new Error(`client api: ${endpoint} has no Client Context binder for ${JSON.stringify(projection.context)}`) + if (boundIdentity === undefined && adapter === undefined) { + throw new Error(`client api: ${endpoint} has no Client Context adapter for ${JSON.stringify(projection.context)}`) } const identity = boundIdentity === undefined - ? binder?.identity(callerCtx) + ? adapter?.identity(callerCtx) : boundIdentity.value if (identity === undefined) { throw new Error(`client api: ${endpoint} requires a ${JSON.stringify(projection.context)} Context`) } - args[projection.wire] = parse(projection.codec, identity, endpoint, projection.wire) + args[projection.wire] = parseInput(projection.codec, identity, endpoint, projection.wire) } let valueIndex = 0 descriptor.parameters.forEach((parameter, parameterIndex) => { if (parameterIndex === projection?.parameterIndex) return - const value = parse(parameter.codec, values[valueIndex], endpoint, parameter.wire) + const value = parseInput(parameter.codec, values[valueIndex], endpoint, parameter.wire) if (value !== undefined) args[parameter.wire] = value valueIndex += 1 }) - const connection = this.ownerCtx.get('connection') as ConnectionHandle | undefined - if (connection === undefined) throw new Error(`client api: ${endpoint} has no active Connection`) const callerSignal = hasCallerSignal ? values[expected] as AbortSignal | undefined : undefined const signal = callerSignal === undefined ? token.abort.signal : AbortSignal.any([token.abort.signal, callerSignal]) - try { - const result = await connection.rpc.call('/api', endpoint, { args }, signal) - if (!mountActive(token)) return withdrawn(endpoint) - if (!result.ok) return { ok: false, error: result.error } - return { ok: true, value: parse(descriptor.result, result.value, endpoint, 'result') } - } catch (error) { - // Carrier throws (offline, abort, a rejected result payload) are outcomes - // of the call, not assembly faults, so they join the same error branch. - return carrierFailure(endpoint, error) - } + return { endpoint, args, signal } } } @@ -420,7 +492,7 @@ type InvokeRemote = ( scoped: ScopedMethod | undefined, callerCtx: Context, args: readonly unknown[], -) => Promise> +) => Promise> | AsyncIterable class RemoteNamespaceService extends Service { private readonly methods = new Map() @@ -475,7 +547,7 @@ class RemoteNamespaceService extends Service { Object.defineProperty(this, method, { configurable: true, enumerable: true, - get: function (this: RemoteNamespaceService): (...args: unknown[]) => Promise> { + get: function (this: RemoteNamespaceService): (...args: unknown[]) => unknown { const callerCtx = this.ctx const current = this.methods.get(method) const direct = current?.direct @@ -504,6 +576,49 @@ class RemoteNamespaceService extends Service { } } +/** + * Install one descriptor group on a namespace service, unwinding the partial + * group when a descriptor is refused. + * @param service - Namespace service taking the methods. + * @param descriptors - Descriptor group of one contribution. + * @returns per-descriptor records for the group disposer. + */ +function installMethods( + service: RemoteNamespaceService, + descriptors: readonly InvocationDescriptor[], +): InstalledMethod[] { + const installed: InstalledMethod[] = [] + try { + for (const descriptor of descriptors) { + const method: InstalledMethod = { + descriptor, + token: { active: true, abort: new AbortController() }, + direct: false, + scoped: false, + } + installed.push(method) + if (descriptor.invocation.kind === 'direct') { + service.installDirect(descriptor, method.token) + method.direct = true + } + const projection = scopedProjection(descriptor) + if (projection !== undefined) { + service.installScoped(descriptor, projection, method.token) + method.scoped = true + } + } + } catch (error) { + for (const method of [...installed].reverse()) { + method.token.active = false + method.token.abort.abort() + if (method.scoped) service.remove('scoped', method.descriptor.method, method.token) + if (method.direct) service.remove('direct', method.descriptor.method, method.token) + } + throw error + } + return installed +} + const REMOTE_NAMESPACE_FIELDS = new Set(['ctx', 'empty', 'invokeRemote', 'methods', 'name', 'namespace']) function remoteServiceKey(namespace: string): string { @@ -548,7 +663,6 @@ function scopedProjection(descriptor: InvocationDescriptor): ScopedProjection | function requireStrictDescriptor(descriptor: InvocationDescriptor): void { const endpoint = endpointOf(descriptor) - requireStrictCodec(descriptor.result, endpoint, 'result') for (const parameter of descriptor.parameters) { requireStrictCodec(parameter.codec, endpoint, parameter.wire) } @@ -563,7 +677,7 @@ function requireStrictCodec(codec: TypertCodec, endpoint: string, field: string) } } -function parse(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown { +function parseInput(codec: TypertCodec, value: unknown, endpoint: string, field: string): unknown { if (codec.mode !== 'strict') { throw new Error(`client api: generated Remote ${endpoint} field ${JSON.stringify(field)} has no strict codec`) } @@ -575,14 +689,37 @@ function parse(codec: TypertCodec, value: unknown, endpoint: string, field: stri } /** The namespace retired before or during the call, so no request outcome exists. */ -function withdrawn(endpoint: string): RemoteResult { +function withdrawn(endpoint: string): Extract, { readonly ok: false }> { return internalFailure(`client api: Remote method ${endpoint} is no longer mounted`) } -function carrierFailure(endpoint: string, error: unknown): RemoteResult { +function carrierFailure(endpoint: string, error: unknown): Extract, { readonly ok: false }> { return internalFailure(`client api: ${endpoint} failed: ${error instanceof Error ? error.message : String(error)}`) } -function internalFailure(message: string): RemoteResult { +function internalFailure(message: string): Extract, { readonly ok: false }> { return { ok: false, error: { code: 'internal', message, details: {} } } } + +type MarkedConnectionStreamFailure = Error & { + readonly dshRemoteStreamFailure?: + | { readonly kind: 'remote'; readonly code: string; readonly details: object } + | { readonly kind: 'carrier' } +} + +/** Preserve Gateway error classes across a worker transport's separately bundled page half. */ +async function *normalizeConnectionStream(source: AsyncIterable): AsyncGenerator { + try { + yield * source + } catch (error) { + if (!(error instanceof Error)) throw error + const marker = (error as MarkedConnectionStreamFailure).dshRemoteStreamFailure + if (marker?.kind === 'remote') { + throw new RemoteStreamError(marker.code, error.message, marker.details) + } + if (marker?.kind === 'carrier') { + throw new RemoteStreamCarrierError(error.message, { cause: error }) + } + throw error + } +} diff --git a/packages/api/gateway/src/client/journal-stream.ts b/packages/api/gateway/src/client/journal-stream.ts new file mode 100644 index 0000000000..7a6f2e8166 --- /dev/null +++ b/packages/api/gateway/src/client/journal-stream.ts @@ -0,0 +1,540 @@ +/** Cursor, page, and live-tail coordination over a reconnecting Remote stream. */ + +import { RemoteStreamCarrierError } from './stream-client.ts' +import type { + RemoteStream, + RemoteStreamItem, + RemoteStreamOptions, +} from './remote-stream.ts' + +/** Transport-neutral opening snapshot or journal entry. */ +export type RemoteJournalFrame = + | { readonly type: 'opened'; readonly cursor: Cursor; readonly page: Page } + | { readonly type: 'entry'; readonly entry: Entry } + +/** One committed journal-window update. */ +export type RemoteJournalChange = + | { + readonly type: 'replace' + readonly page: Page + readonly entries: readonly Entry[] + readonly hasMore: boolean + } + | { + readonly type: 'prepend' + readonly page: Page + readonly entries: readonly Entry[] + readonly hasMore: boolean + } + | { readonly type: 'append'; readonly entry: Entry } + +type JournalStreamItem = RemoteStreamItem> + +/** Gateway capability used to create one reconnecting Remote stream. */ +export interface RemoteStreamFactory { + /** + * Create one independently cancellable logical stream. + * @param options - domain-owned opener and generation-end classification. + * @returns a reconnecting single-consumer stream. + */ + $stream(options: RemoteStreamOptions): RemoteStream +} + +/** Domain publication and cursor operations for one addressed journal stream. */ +export interface RemoteJournalStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string + /** Cursor representing a journal with no entries. */ + readonly emptyCursor: Cursor + /** Read the ordered entries carried by a page. */ + readonly entries: (page: Page) => readonly Entry[] + /** Read whether an older page exists. */ + readonly hasMore: (page: Page) => boolean + /** Read the inclusive first durable cursor covered by one entry. */ + readonly first: (entry: Entry) => Cursor + /** Read the inclusive final cursor, which must not precede the first. */ + readonly last: (entry: Entry) => Cursor + /** Compare two cursors. */ + readonly compare: (left: Cursor, right: Cursor) => number + /** Test whether the right cursor immediately follows the left cursor. */ + readonly follows: (left: Cursor, right: Cursor) => boolean + /** Apply one complete journal-window change. */ + readonly publish: (change: RemoteJournalChange) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void +} + +/** + * Owns snapshot-first opening, ordered live delivery, pagination, and repair. + * + * The domain retains its published window during reconnection. A replacement is + * published only after the opening page reaches the generation's cursor. + */ +export abstract class RemoteJournalStream { + private readonly stream: RemoteStream> + private initialRequest!: PageRequest + private resumeCursor: Cursor | undefined + private hasResumeCursor = false + private generation = 0 + private firstCursor: Cursor | undefined + private lastCursor: Cursor | undefined + private started = false + private opened = false + private disposed = false + private done: Promise | undefined + private closing: Promise | undefined + private pendingNext: Promise>> | undefined + + /** + * @param remote - Gateway factory for the reconnecting physical-generation stream. + * @param options - cursor algebra and domain publication sinks. + */ + protected constructor( + remote: RemoteStreamFactory, + private readonly options: RemoteJournalStreamOptions, + ) { + this.stream = remote.$stream>({ + name: options.name, + open: signal => this.follow(this.initialRequest, signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError(`${options.name} ended without a terminal result`) + : new Error( + `${this.hasResumeCursor ? 'resumed ' : ''}${options.name} ended before its opening cursor`, + ), + ...(options.carrierFailed === undefined + ? {} + : { carrierFailed: options.carrierFailed }), + }) + } + + /** + * Open one physical journal generation with a complete current snapshot. + * @param request - opening-window request retained for later repair. + * @param signal - cancellation lifetime of the physical generation. + * @returns opening cursor followed by live entries. + */ + protected abstract follow( + request: PageRequest, + signal: AbortSignal, + ): AsyncIterable> + + /** + * Read one journal page through the addressed domain source. + * @param request - domain page request. + * @param through - inclusive journal cursor that fixes the source read. + * @param signal - cancellation lifetime shared with the logical stream. + * @returns the requested page, whose tail equals `through` unless the domain request selects older entries. + */ + protected abstract readPage(request: PageRequest, through: Cursor, signal: AbortSignal): Promise + + /** + * Derive an unbounded-tail request from the initial page request. + * @param initial - request used to open the journal window. + * @returns request suitable for reconnect and gap repair. + */ + protected abstract repairRequest(initial: PageRequest): PageRequest + + /** Cancellation lifetime shared by follow and page calls. */ + get signal(): AbortSignal { + return this.stream.signal + } + + /** + * Establish follow and publish the opening snapshot carried by its first frame. + * @param request - initial tail-page request. + * @returns after the first complete window is published. + */ + async open(request: PageRequest): Promise { + if (this.started) throw new Error(`${this.options.name} already opened`) + this.started = true + this.initialRequest = request + const iterator = this.stream[Symbol.asyncIterator]() + try { + const first = await this.takeNext(iterator) + if (first.done) throw new Error(`${this.options.name} ended before its opening cursor`) + this.replaceGeneration(first.value, false) + this.opened = true + this.done = this.consume(iterator) + } catch (error) { + await this.stream.dispose() + throw error + } + } + + /** + * Read and prepend one older page after a successful open. + * @param request - domain page request bound to this stream's address. + * @returns after the page is applied or rejected as discontinuous. + */ + async prepend(request: PageRequest): Promise { + if (!this.opened || this.disposed) throw new Error(`${this.options.name} is not open`) + const page = await this.readPage(request, this.currentCursor(), this.stream.signal) + this.stream.signal.throwIfAborted() + const entries = this.options.entries(page) + this.assertPage(entries) + const before = this.firstCursor + const accepted = before === undefined + ? [...entries] + : entries.filter(entry => this.options.compare(this.options.first(entry), before) < 0) + const tail = accepted.at(-1) + if (tail !== undefined && before !== undefined + && !this.options.follows(this.options.last(tail), before)) { + this.options.publish({ type: 'prepend', page, entries: [], hasMore: false }) + throw new Error(`${this.options.name} history page is discontinuous`) + } + const first = accepted[0] + if (first !== undefined) this.firstCursor = this.options.first(first) + this.options.publish({ + type: 'prepend', + page, + entries: accepted, + hasMore: this.options.hasMore(page), + }) + } + + /** Replace the active physical generation while retaining the published window. */ + restart(): void { + this.stream.restart() + } + + /** + * Permanently stop follow, page requests, and the background consumer. + * @returns when no stream work or publication callback can still run. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing + this.disposed = true + const done = this.done + const closing = (async () => { + await this.stream.dispose() + await done + })() + this.closing = closing + return closing + } + + private async consume( + iterator: AsyncIterator>, + ): Promise { + try { + while (true) { + const next = await this.takeNext(iterator) + if (next.done) return + const item = next.value + if (item.generation !== this.generation) { + this.replaceGeneration(item, true) + continue + } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + await this.acceptEntry(item.value.entry, item, iterator) + } + } catch (error) { + if (!this.disposed) this.options.failed(error) + } + } + + private replaceGeneration( + initial: JournalStreamItem, + resumed: boolean, + ): void { + const opening = this.opening(initial, resumed) + this.replaceFromOpening(opening.page, opening.cursor) + } + + private opening( + item: RemoteStreamItem>, + resumed: boolean, + ): { readonly cursor: Cursor; readonly page: Page } { + if (item.value.type !== 'opened') { + throw new Error(`${resumed ? 'resumed ' : ''}${this.options.name} emitted an entry before its opening cursor`) + } + const cursor = item.value.cursor + if (resumed && this.lastCursor !== undefined + && this.options.compare(cursor, this.lastCursor) < 0) { + throw new Error( + `${this.options.name} resumed at a cursor behind the last applied entry`, + ) + } + this.generation = item.generation + item.accept() + return { cursor, page: item.value.page } + } + + /** Publish a generation's opening page without issuing a second Remote call. */ + private replaceFromOpening(page: Page, cursor: Cursor): void { + this.assertPageThrough(page, cursor) + const entries = [...this.options.entries(page)] + this.assertPage(entries) + const first = entries[0] + this.firstCursor = first === undefined ? undefined : this.options.first(first) + this.lastCursor = cursor + this.setResumeCursor(cursor) + this.options.publish({ + type: 'replace', + page, + entries, + hasMore: this.options.hasMore(page), + }) + } + + private async acceptEntry( + entry: Entry, + item: JournalStreamItem, + iterator: AsyncIterator>, + ): Promise { + const { first, last: cursor } = this.entryRange(entry) + const last = this.lastCursor as Cursor + if (this.options.compare(cursor, last) <= 0) return + if (this.options.compare(first, last) <= 0) { + throw new Error(`${this.options.name} emitted a partially overlapping entry`) + } + if (!this.options.follows(last, first)) { + const request = this.repairPageRequest() + const superseded = await this.replaceThrough( + request, + cursor, + item.generation, + item.signal, + iterator, + [entry], + ) + if (superseded !== undefined) { + this.replaceGeneration(superseded, true) + } + return + } + if (this.firstCursor === undefined) this.firstCursor = first + this.lastCursor = cursor + this.setResumeCursor(cursor) + this.options.publish({ type: 'append', entry }) + } + + private async replaceThrough( + request: PageRequest, + requiredCursor: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + ): Promise | undefined> { + let read = await this.readPageWhileFollowing( + request, + requiredCursor, + generation, + signal, + iterator, + queued, + ) + if (read.type === 'superseded') return read.item + let page = read.page + this.assertPageThrough(page, requiredCursor) + let entries = this.mergeReplacement(page, queued) + let target = this.maxCursor(requiredCursor, queued) + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + read = await this.readPageWhileFollowing( + this.repairPageRequest(), + target, + generation, + signal, + iterator, + queued, + ) + if (read.type === 'superseded') return read.item + page = read.page + this.assertPageThrough(page, target) + entries = this.mergeReplacement(page, queued) + target = this.maxCursor(requiredCursor, queued) + } + if (entries === undefined || this.options.compare(this.tailCursor(entries), target) < 0) { + throw new Error(`${this.options.name} page did not reach its opening cursor`) + } + const first = entries[0] + /* v8 ignore next -- a successful positive-cursor replacement page cannot be empty. */ + this.firstCursor = first === undefined ? undefined : this.options.first(first) + this.lastCursor = this.tailCursor(entries) + this.setResumeCursor(this.lastCursor) + this.options.publish({ + type: 'replace', + page, + entries, + hasMore: this.options.hasMore(page), + }) + return undefined + } + + private async readPageWhileFollowing( + request: PageRequest, + through: Cursor, + generation: number, + signal: AbortSignal, + iterator: AsyncIterator>, + queued: Entry[], + ): Promise< + | { readonly type: 'page'; readonly page: Page } + | { readonly type: 'superseded'; readonly item: JournalStreamItem } + > { + const page = this.readPage(request, through, signal).then( + value => ({ type: 'page' as const, value }), + (error: unknown) => ({ type: 'page-error' as const, error }), + ) + while (true) { + const pending = this.nextResult(iterator) + const next = pending.then( + value => ({ type: 'next' as const, value }), + (error: unknown) => ({ type: 'next-error' as const, error }), + ) + const result = await Promise.race([page, next]) + if (result.type === 'page') { + signal.throwIfAborted() + return { type: 'page', page: result.value } + } + if (result.type === 'page-error') { + if (!signal.aborted || this.stream.signal.aborted) throw result.error + return this.awaitReplacementGeneration(generation, iterator, pending) + } + this.releaseNext() + if (result.type === 'next-error') throw result.error + if (result.value.done) { + signal.throwIfAborted() + throw new Error(`${this.options.name} ended while reading its replacement page`) + } + const item = result.value.value + if (item.generation !== generation) return { type: 'superseded', item } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + queued.push(item.value.entry) + } + } + + private async awaitReplacementGeneration( + generation: number, + iterator: AsyncIterator>, + initial: Promise>>, + ): Promise<{ readonly type: 'superseded'; readonly item: JournalStreamItem }> { + let pending = initial + while (true) { + let next: IteratorResult> + try { + next = await pending + } finally { + this.releaseNext() + } + if (next.done) { + this.stream.signal.throwIfAborted() + throw new Error(`${this.options.name} ended while replacing an aborted page generation`) + } + const item = next.value + if (item.generation !== generation) return { type: 'superseded', item } + if (item.value.type === 'opened') { + throw new Error(`${this.options.name} emitted more than one opening cursor`) + } + pending = this.nextResult(iterator) + } + } + + private mergeReplacement(page: Page, queued: readonly Entry[]): Entry[] | undefined { + const entries = [...this.options.entries(page)] + this.assertPage(entries) + for (const entry of queued) this.entryRange(entry) + const sorted = [...queued].sort((left, right) => ( + this.options.compare(this.options.first(left), this.options.first(right)) + )) + let tail = this.tailCursor(entries) + for (const entry of sorted) { + const first = this.options.first(entry) + const last = this.options.last(entry) + if (this.options.compare(last, tail) <= 0) continue + if (this.options.compare(first, tail) <= 0) { + throw new Error(`${this.options.name} replacement contains a partially overlapping entry`) + } + if (!this.options.follows(tail, first)) return undefined + entries.push(entry) + tail = last + } + return entries + } + + private maxCursor(cursor: Cursor, entries: readonly Entry[]): Cursor { + let result = cursor + for (const entry of entries) { + const candidate = this.options.last(entry) + if (this.options.compare(candidate, result) > 0) result = candidate + } + return result + } + + private nextResult( + iterator: AsyncIterator>, + ): Promise>> { + this.pendingNext ??= iterator.next() + return this.pendingNext + } + + private async takeNext( + iterator: AsyncIterator>, + ): Promise>> { + const pending = this.nextResult(iterator) + try { + return await pending + } finally { + this.releaseNext() + } + } + + private releaseNext(): void { + this.pendingNext = undefined + } + + private repairPageRequest(): PageRequest { + return this.repairRequest(this.initialRequest) + } + + private setResumeCursor(cursor: Cursor): void { + this.resumeCursor = cursor + this.hasResumeCursor = true + } + + private currentCursor(): Cursor { + return this.resumeCursor as Cursor + } + + private tailCursor(entries: readonly Entry[]): Cursor { + const tail = entries.at(-1) + return tail === undefined ? this.options.emptyCursor : this.options.last(tail) + } + + private assertPage(entries: readonly Entry[]): void { + const iterator = entries[Symbol.iterator]() + const first = iterator.next() + if (first.done) return + let previousRange = this.entryRange(first.value) + for (const entry of iterator) { + const range = this.entryRange(entry) + if (!this.options.follows(previousRange.last, range.first)) { + throw new Error(`${this.options.name} page contains discontinuous entries`) + } + previousRange = range + } + } + + private entryRange(entry: Entry): { readonly first: Cursor; readonly last: Cursor } { + const first = this.options.first(entry) + const last = this.options.last(entry) + if (this.options.compare(first, last) > 0) { + throw new Error(`${this.options.name} entry has an inverted cursor range`) + } + return { first, last } + } + + private assertPageThrough(page: Page, through: Cursor): void { + const tail = this.tailCursor(this.options.entries(page)) + if (this.options.compare(tail, through) !== 0) { + throw new Error(`${this.options.name} page did not end at its requested cursor`) + } + } +} diff --git a/packages/api/gateway/src/client/remote-events.ts b/packages/api/gateway/src/client/remote-events.ts new file mode 100644 index 0000000000..341c1f3f4d --- /dev/null +++ b/packages/api/gateway/src/client/remote-events.ts @@ -0,0 +1,339 @@ +/** Client owner for forwarded Remote Event subscriptions and deliveries. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { + ConnectionGenerationSource, + ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' +import type { + TypertClientEventListener, + TypertRemoteEvent, +} from '@deepseek-ai/dsh-typert-protocol' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import { + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + isRemoteEventAgentId, + isRemoteEventClientId, + isRemoteEventId, + isRemoteJsonValue, + projectRemoteEventRejection, + type RemoteEventClientId, + type RemoteEventDownlinkFrame, + type RemoteEventEmitFrame, + type RemoteEventInvocationFrame, + type RemoteEventResult, +} from '../stream-protocol.ts' + +/** Open the Gateway-internal forwarded-event stream on the selected carrier. */ +export type RemoteEventStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable + +/** One subscribed listener after its event-specific signature is erased. */ +type RemoteEventListener = (this: Context, ...args: unknown[]) => unknown + +/** Untyped access used only for instance-private Cordis event keys. */ +interface PrivateEventContext { + on(name: string, listener: RemoteEventListener): () => boolean + parallel(name: string, ...args: unknown[]): Promise + waterfall( + thisArg: Context, + name: string, + request: Readonly>, + next: () => Promise, + ): unknown +} + +/** Transport outcome after one Client listener chain either claims or delegates. */ +type RemoteEventReplyOutcome = + | { readonly kind: 'result'; readonly value: unknown } + | { readonly kind: 'next' } + | { readonly kind: 'rejected'; readonly error: ReturnType } + +/** Private end-of-chain marker that cannot collide with a JSON listener result. */ +const REMOTE_EVENT_NEXT = Symbol('api-gateway.remote-event.next') + +/** Own Cordis registrations, generation pumping, waterfall dispatch, and HTTP replies. */ +export class ClientRemoteEvents { + private readonly eventPrefix = `internal/api-gateway/remote-event/${randomUUID()}/` + private readonly unregisterGeneration: () => void + private activeGeneration: Promise | undefined + + /** + * @param ownerCtx - Client Gateway root used for Agent Context resolution. + * @param connection - Connection carrier used for HTTP result calls. + * @param openStream - selected in-process or WebSocket stream opener. + */ + constructor( + private readonly ownerCtx: Context, + private readonly connection: ConnectionHandle, + private readonly openStream: RemoteEventStreamOpener, + ) { + this.unregisterGeneration = connection.registerGenerationSource(this.runGeneration) + } + + /** + * Register one typed Remote Event listener in its calling fiber. + * @param callerCtx - fiber Context owning the registration. + * @param event - selected forwarded event. + * @param listener - listener derived from that event's declaration. + * @returns disposer for this exact registration. + */ + subscribe( + callerCtx: Context, + event: Event, + listener: TypertClientEventListener, + ): () => void { + const dispose = privateEvents(callerCtx).on( + this.eventKey(event), + listener as unknown as RemoteEventListener, + ) + return () => { dispose() } + } + + /** Withdraw the generation source and wait for active listener work to quiesce. */ + async dispose(): Promise { + this.unregisterGeneration() + await Promise.allSettled([this.activeGeneration]) + } + + /** Track the current generation so plugin disposal waits for listener work to stop. */ + private readonly runGeneration: ConnectionGenerationSource = (signal, ready) => { + const tracked = this.pumpEvents(signal, ready).finally(() => { + if (this.activeGeneration === tracked) this.activeGeneration = undefined + }) + this.activeGeneration = tracked + return tracked + } + + /** Deliver one notification through Cordis while containing listener failures. */ + private deliver(frame: RemoteEventEmitFrame): void { + void privateEvents(this.ownerCtx) + .parallel(this.eventKey(frame.event), ...frame.args) + .catch((error: unknown) => { this.reportError(frame.event, error) }) + } + + /** Run one Connection generation over the forwarded-event logical stream. */ + private async pumpEvents(signal: AbortSignal, ready: () => void): Promise { + let clientId: RemoteEventClientId | undefined + const failed = new AbortController() + const generationSignal = AbortSignal.any([signal, failed.signal]) + const active = new Map() + const tasks = new Set>() + const source = this.openStream( + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_PAYLOAD, + generationSignal, + ) + let streamFailed = false + let streamError: unknown + try { + for await (const value of source) { + if (clientId === undefined) { + clientId = parseRemoteEventReady(value) + ready() + continue + } + const frame = parseRemoteEventFrame(value) + if (frame.type === 'cancel') { + active.get(frame.eventId)?.abort(new Error('client api: Remote event was cancelled by the Host')) + continue + } + if (frame.type === 'emit') { + this.deliver(frame) + continue + } + const controller = new AbortController() + active.set(frame.eventId, controller) + const deliverySignal = AbortSignal.any([generationSignal, controller.signal]) + const task = this.answer(frame, clientId, deliverySignal) + .catch((error: unknown) => { + if (!deliverySignal.aborted) failed.abort(error) + }) + .finally(() => { + active.delete(frame.eventId) + tasks.delete(task) + }) + tasks.add(task) + } + } catch (error) { + streamFailed = true + streamError = error + } finally { + for (const controller of active.values()) { + controller.abort(new Error('client api: Remote event generation ended')) + } + await Promise.allSettled(tasks) + } + if (failed.signal.aborted) { + throw toError(failed.signal.reason, 'client api: Remote event result delivery failed') + } + if (signal.aborted) return + if (streamFailed) throw streamError + throw new Error('client api: forwarded Remote event stream ended unexpectedly') + } + + private async answer( + frame: RemoteEventInvocationFrame, + clientId: RemoteEventClientId, + signal: AbortSignal, + ): Promise { + const adapter = this.ownerCtx.typert.contexts.getClient('agent') + let target: Context | undefined + try { + target = adapter?.resolve(frame.agentId) + } catch (error) { + this.reportError(frame.event, error) + } + let outcome: RemoteEventReplyOutcome = { kind: 'next' } + if (target !== undefined) { + try { + outcome = await this.dispatchWaterfall(target, frame, signal) + } catch (error) { + if (signal.aborted) return + outcome = { kind: 'rejected', error: projectRemoteEventRejection(error) } + } + } + if (signal.aborted) return + const result: RemoteEventResult = { + clientId, + eventId: frame.eventId, + outcome: outcome.kind === 'result' && outcome.value === undefined + ? { kind: 'result' } + : outcome, + } + const response = await this.connection.rpc.call( + '/api', + REMOTE_EVENT_RESULT_ENDPOINT, + { args: result }, + signal, + ) + if (!response.ok) throw new Error(response.error.message) + } + + private async dispatchWaterfall( + target: Context, + frame: RemoteEventInvocationFrame, + signal: AbortSignal, + ): Promise { + const request = { + ...frame.request, + agent: target, + signal, + } + const value = await abortable( + Promise.resolve(privateEvents(target).waterfall( + target, + this.eventKey(frame.event), + request, + () => Promise.resolve(REMOTE_EVENT_NEXT), + )), + signal, + ) + if (value !== REMOTE_EVENT_NEXT && value !== undefined && !isRemoteJsonValue(value)) { + throw new TypeError('Remote event listener result is not lossless JSON data') + } + return value === REMOTE_EVENT_NEXT + ? { kind: 'next' } + : { kind: 'result', value } + } + + private eventKey(event: string): string { + return `${this.eventPrefix}${event}` + } + + private reportError(event: string, error: unknown): void { + console.error(`client api: Remote event ${JSON.stringify(event)} listener threw:`, error) + } +} + +/** Validate and return the Client identity from one generation's opening item. */ +function parseRemoteEventReady(value: unknown): RemoteEventClientId { + if (!isRemoteEventRecord(value) + || !hasExactRemoteEventKeys(value, ['type', 'clientId']) + || value.type !== 'ready' + || !isRemoteEventClientId(value.clientId)) { + throw new TypeError('client api: forwarded Remote event stream did not begin with ready') + } + return value.clientId +} + +/** Validate one untrusted value from the Gateway-internal forwarded-event stream. */ +function parseRemoteEventFrame(value: unknown): Exclude { + if (!isRemoteEventRecord(value)) invalidRemoteEventFrame() + if (value.type === 'cancel' + && hasExactRemoteEventKeys(value, ['type', 'eventId']) + && isRemoteEventId(value.eventId)) { + return { type: 'cancel', eventId: value.eventId } + } + if (value.type === 'emit' + && hasExactRemoteEventKeys(value, ['type', 'event', 'args']) + && validRemoteEventName(value.event) + && Array.isArray(value.args) + && isRemoteJsonValue(value.args)) { + return { type: 'emit', event: value.event, args: value.args } + } + if (value.type === 'waterfall' + && hasExactRemoteEventKeys(value, ['type', 'event', 'eventId', 'agentId', 'request']) + && validRemoteEventName(value.event) + && isRemoteEventId(value.eventId) + && isRemoteEventAgentId(value.agentId) + && isRemoteEventRecord(value.request) + && !Object.hasOwn(value.request, 'agent') + && !Object.hasOwn(value.request, 'signal') + && isRemoteJsonValue(value.request)) { + return { + type: 'waterfall', + event: value.event, + eventId: value.eventId, + agentId: value.agentId, + request: value.request, + } + } + invalidRemoteEventFrame() +} + +function isRemoteEventRecord(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function hasExactRemoteEventKeys(value: Record, keys: readonly string[]): boolean { + const ownKeys = Reflect.ownKeys(value) + return ownKeys.length === keys.length && keys.every(key => Object.hasOwn(value, key)) +} + +function validRemoteEventName(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function invalidRemoteEventFrame(): never { + throw new TypeError('client api: invalid forwarded Remote event frame') +} + +/** Race listener completion against its delivery lifetime. */ +async function abortable(value: T | PromiseLike, signal: AbortSignal): Promise { + signal.throwIfAborted() + let rejectAbort: ((reason: unknown) => void) | undefined + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { rejectAbort?.(signal.reason) } + signal.addEventListener('abort', onAbort, { once: true }) + try { + return await Promise.race([Promise.resolve(value), aborted]) + } finally { + signal.removeEventListener('abort', onAbort) + } +} + +function privateEvents(ctx: Context): PrivateEventContext { + return ctx +} + +function toError(reason: unknown, message: string): Error { + return reason instanceof Error ? reason : new Error(message, { cause: reason }) +} diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts new file mode 100644 index 0000000000..799a371ef5 --- /dev/null +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -0,0 +1,210 @@ +/** Reconnecting lifecycle for one single-consumer Remote stream. */ + +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { RemoteStreamCarrierError } from './stream-client.ts' + +/** One item annotated with the physical Remote-stream generation that delivered it. */ +export interface RemoteStreamItem { + /** Monotone physical generation number within this logical stream. */ + readonly generation: number + /** Decoded item yielded by the generated Remote method. */ + readonly value: Item + /** Cancellation lifetime of the generation that delivered this item. */ + readonly signal: AbortSignal + /** Mark this generation's opening baseline or cursor as accepted. */ + accept(): void +} + +/** Domain-owned operations used by {@link RemoteStream}. */ +export interface RemoteStreamOptions { + /** Diagnostic owner name used for cancellation failures. */ + readonly name: string + /** Open one physical generation of the logical stream. */ + readonly open: (signal: AbortSignal) => AsyncIterable + /** Classify a normal generation end after or before its opening item was accepted. */ + readonly ended: (accepted: boolean) => Error + /** Observe a retryable carrier loss before the supervisor waits or reopens. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void +} + +/** + * Reopens one logical Remote stream across carrier generations. + * + * The Gateway owns physical retry timing, cancellation, and replacement. The + * domain consumer owns its opening item and every later item, and calls + * {@link RemoteStreamItem.accept} only after validating the opening + * baseline or cursor. + */ +export class RemoteStream implements AsyncIterable> { + private readonly lifetime = new AbortController() + private generationAbort: AbortController | undefined + private iterator: AsyncGenerator> | undefined + private closing: Promise | undefined + private revision = 0 + private taken = false + + /** + * @param connection - observable Host generation source used to pace retries. + * @param options - domain stream opener, end classification, and diagnostics. + */ + constructor( + private readonly connection: Pick, + private readonly options: RemoteStreamOptions, + ) {} + + /** Cancellation lifetime shared by the stream and sibling page requests. */ + get signal(): AbortSignal { + return this.lifetime.signal + } + + /** Interrupt the current generation and immediately request a replacement. */ + restart(): void { + if (this.lifetime.signal.aborted) return + this.revision++ + this.generationAbort?.abort(new Error(`${this.options.name} generation restarted`)) + } + + /** + * Permanently stop this stream and wait for its iterator to close. + * @returns when the active generation and consumer iterator are quiescent. + */ + dispose(): Promise { + if (this.closing !== undefined) return this.closing + if (!this.lifetime.signal.aborted) { + const reason = new Error(`${this.options.name} disposed`) + this.lifetime.abort(reason) + this.generationAbort?.abort(reason) + } + const iterator = this.iterator + if (iterator === undefined) return Promise.resolve() + const closing = closeRemoteStreamIterator(iterator) + this.closing = closing + return closing + } + + /** @inheritdoc */ + [Symbol.asyncIterator](): AsyncIterator> { + if (this.taken) throw new Error(`${this.options.name} already has a consumer`) + this.taken = true + const iterator = this.read() + this.iterator = iterator + return iterator + } + + private async * read(): AsyncGenerator> { + let attempt = 0 + let generation = 0 + let observedRevision = this.revision + try { + while (!isAborted(this.lifetime.signal)) { + if (observedRevision !== this.revision) { + observedRevision = this.revision + attempt = 0 + } + const revision = this.revision + const generationAbort = new AbortController() + this.generationAbort = generationAbort + const signal = AbortSignal.any([this.lifetime.signal, generationAbort.signal]) + const generationId = ++generation + let accepted = false + try { + for await (const value of this.options.open(signal)) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) break + yield { + generation: generationId, + value, + signal, + accept: () => { + if (this.generationAbort !== generationAbort || revision !== this.revision) return + accepted = true + attempt = 0 + }, + } + } + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + throw this.options.ended(accepted) + } catch (error) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + if (!(error instanceof RemoteStreamCarrierError)) throw error + this.options.carrierFailed?.(error) + if (revision !== this.revision) continue + attempt++ + try { + await waitForRemoteStreamRetry(this.connection, error, attempt, signal) + } catch (retryError) { + if (isAborted(this.lifetime.signal)) return + if (revision !== this.revision) continue + throw retryError + } + } finally { + this.generationAbort = undefined + if (!generationAbort.signal.aborted) { + generationAbort.abort(new Error(`${this.options.name} generation ended`)) + } + } + } + } finally { + if (!this.lifetime.signal.aborted) { + this.lifetime.abort(new Error(`${this.options.name} consumer closed`)) + } + this.generationAbort?.abort(this.lifetime.signal.reason) + this.generationAbort = undefined + } + } +} + +async function waitForRemoteStreamRetry( + connection: Pick, + error: RemoteStreamCarrierError, + attempt: number, + signal: AbortSignal, +): Promise { + signal.throwIfAborted() + if (connection.hostDescription.getSnapshot() !== undefined) { + if (attempt === 1) return + throw error + } + await new Promise((resolve, reject) => { + const subscription: { + dispose?: () => void + finished: boolean + } = { finished: false } + const finish = (failure?: Error): void => { + if (subscription.finished) return + subscription.finished = true + subscription.dispose?.() + signal.removeEventListener('abort', aborted) + if (failure === undefined) resolve() + else reject(failure) + } + const inspect = (): void => { + if (connection.hostDescription.getSnapshot() !== undefined) finish() + } + const aborted = (): void => { + finish(new Error('Remote stream retry aborted', { cause: signal.reason })) + } + const dispose = connection.hostDescription.subscribe(inspect) + subscription.dispose = dispose + if (subscription.finished) dispose() + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) aborted() + else inspect() + }) +} + +function isAborted(signal: AbortSignal): boolean { + return signal.aborted +} + +async function closeRemoteStreamIterator( + iterator: AsyncIterator>, +): Promise { + try { + await iterator.return?.() + } catch { + // The disposed logical stream has no remaining consumer for cancellation failures. + } +} diff --git a/packages/api/gateway/src/client/snapshot-stream.ts b/packages/api/gateway/src/client/snapshot-stream.ts new file mode 100644 index 0000000000..daa9660df9 --- /dev/null +++ b/packages/api/gateway/src/client/snapshot-stream.ts @@ -0,0 +1,88 @@ +/** Baseline-and-delta protocol layered over a reconnecting Remote stream. */ + +import type { RemoteStream } from './remote-stream.ts' + +/** Domain operations for one snapshot stream. */ +export interface RemoteSnapshotStreamOptions { + /** Diagnostic stream name used in protocol failures. */ + readonly name: string + /** Distinguish the opening snapshot from later deltas. */ + readonly isSnapshot: (value: Snapshot | Delta) => value is Snapshot + /** Atomically replace the domain model from a complete snapshot. */ + readonly replace: (snapshot: Snapshot) => void + /** Apply one incremental update after the generation snapshot. */ + readonly update: (delta: Delta) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** + * Consumes generations that each contain exactly one opening snapshot followed by deltas. + * + * The previous domain snapshot remains published while the underlying stream retries. A + * replacement becomes accepted only after the domain owner applies it successfully. + */ +export class RemoteSnapshotStream { + private started = false + private disposed = false + private done: Promise | undefined + + /** + * @param stream - reconnecting physical-generation stream. + * @param options - frame discriminator and domain state destinations. + */ + constructor( + private readonly stream: RemoteStream, + private readonly options: RemoteSnapshotStreamOptions, + ) {} + + /** Start the single consumer; repeated calls are inert. */ + start(): void { + if (this.started) return + this.started = true + this.done = this.consume() + } + + /** Replace the active physical generation without discarding the published snapshot. */ + restart(): void { + this.stream.restart() + } + + /** + * Permanently stop the stream and wait for its consumer to become quiescent. + * @returns when no generation or callback can still run. + */ + async dispose(): Promise { + this.disposed = true + await this.stream.dispose() + await this.done + } + + private async consume(): Promise { + let generation = 0 + let snapshotSeen = false + try { + for await (const item of this.stream) { + if (item.generation !== generation) { + generation = item.generation + snapshotSeen = false + } + if (this.options.isSnapshot(item.value)) { + if (snapshotSeen) { + throw new Error(`${this.options.name} emitted more than one opening snapshot`) + } + this.options.replace(item.value) + snapshotSeen = true + item.accept() + continue + } + if (!snapshotSeen) { + throw new Error(`${this.options.name} emitted an update before its opening snapshot`) + } + this.options.update(item.value) + } + } catch (error) { + if (!this.disposed) this.options.failed(error) + } + } +} diff --git a/packages/api/gateway/src/client/stream-client.ts b/packages/api/gateway/src/client/stream-client.ts new file mode 100644 index 0000000000..0dd56cc72d --- /dev/null +++ b/packages/api/gateway/src/client/stream-client.ts @@ -0,0 +1,348 @@ +/** Browser owner for the Gateway multiplexed Remote stream socket. */ + +import { + parseRemoteStreamServerMessage, + REMOTE_STREAM_MUX_PATH, + type RemoteStreamClientMessage, + type RemoteStreamServerMessage, +} from '../stream-protocol.ts' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' + +const INTERNAL_BASE = 'http://dsh.internal' +const RECONNECT_BASE_MS = 500 +const RECONNECT_FACTOR = 2 +const RECONNECT_MAX_MS = 10_000 + +/** One Host-reported Remote stream failure. */ +export class RemoteStreamError extends Error { + /** Stable carrier or Gateway error category. */ + readonly code: string + /** Host-provided structured failure context. */ + readonly details: object + + /** + * @param code - stable Gateway or business error category. + * @param message - Host-provided failure description. + * @param details - Host-provided structured failure context. + */ + constructor(code: string, message: string, details: object) { + super(message) + this.name = 'RemoteStreamError' + this.code = code + this.details = details + } +} + +/** Physical Remote stream socket failure that may be retried by a domain transport. */ +export class RemoteStreamCarrierError extends Error { + /** + * @param message - physical carrier failure description. + * @param options - optional causal error. + */ + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'RemoteStreamCarrierError' + } +} + +interface SocketWaiter { + resolve(socket: WebSocket): void + reject(error: unknown): void +} + +/** Keep one physical WebSocket and share it among independently cancellable Remote streams. */ +export class RemoteStreamMuxClient { + private socket: WebSocket | undefined + private cancelCandidate: ((error: Error) => void) | undefined + private keepAlive: Promise | undefined + private keepAliveAbort: AbortController | undefined + private readonly streams = new Map() + private readonly waiters = new Set() + private running = false + private disposed = false + + /** Start the persistent physical connection; repeated calls are inert. */ + start(): void { + if (this.running || this.disposed) return + this.running = true + this.maintain() + } + + /** + * Open one logical stream on the persistent physical connection. + * @param endpoint - Typert Remote stream endpoint. + * @param payload - endpoint request encoded on the wire. + * @param signal - cancellation for this logical stream. + * @returns Host items until completion, cancellation, or failure. + */ + async *open( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator { + this.start() + signal.throwIfAborted() + const streamId = randomUUID() + const inbox = new StreamInbox() + let carrier: WebSocket | undefined + let opened = false + let terminal = false + const abort = (): void => { inbox.fail(signal.reason) } + signal.addEventListener('abort', abort, { once: true }) + try { + const socket = await this.waitForSocket(signal) + signal.throwIfAborted() + carrier = socket + this.streams.set(streamId, inbox) + this.send(socket, { type: 'open', streamId, endpoint, payload }) + opened = true + while (true) { + const frame = await inbox.next() + signal.throwIfAborted() + if (frame.type === 'item') { + yield frame.value + continue + } + terminal = true + if (frame.type === 'error') { + throw new RemoteStreamError(frame.error.code, frame.error.message, frame.error.details) + } + return + } + } finally { + signal.removeEventListener('abort', abort) + this.streams.delete(streamId) + if (opened && !terminal && carrier?.readyState === WebSocket.OPEN) { + this.send(carrier, { type: 'cancel', streamId }) + } + } + } + + /** + * Permanently stop reconnecting, close the physical socket, and fail every active logical stream. + * @returns once the background connection loop has stopped. + */ + async close(): Promise { + if (!this.disposed) { + this.disposed = true + this.running = false + const error = new Error('api gateway: Remote stream client disposed') + this.keepAliveAbort?.abort(error) + this.keepAliveAbort = undefined + this.failAll(error) + for (const waiter of [...this.waiters]) waiter.reject(error) + this.cancelCandidate?.(error) + const socket = this.socket + this.socket = undefined + socket?.close(1000, 'disposed') + } + await this.keepAlive + } + + private connect(): Promise { + const socket = new WebSocket(remoteStreamUrl()) + const connecting = new Promise((resolve, reject) => { + let settled = false + const rejectCandidate = (error: Error): void => { + settled = true + socket.removeEventListener('open', opened) + socket.removeEventListener('error', failed) + socket.removeEventListener('message', received) + socket.removeEventListener('close', closed) + this.cancelCandidate = undefined + socket.close() + reject(error) + } + const opened = (): void => { + settled = true + this.cancelCandidate = undefined + this.socket = socket + for (const waiter of [...this.waiters]) waiter.resolve(socket) + resolve(socket) + } + const failed = (): void => { + if (!settled) { + rejectCandidate(new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket failed to open', + )) + return + } + const error = new RemoteStreamCarrierError('api gateway: Remote stream WebSocket failed') + this.lost(socket, error) + socket.close() + } + const closed = (): void => { + if (!settled) { + rejectCandidate(new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket closed before opening', + )) + return + } + this.lost(socket) + } + const received = (event: MessageEvent): void => { this.receive(socket, event.data) } + this.cancelCandidate = rejectCandidate + socket.addEventListener('open', opened, { once: true }) + socket.addEventListener('error', failed, { once: true }) + socket.addEventListener('message', received) + socket.addEventListener('close', closed, { once: true }) + }) + return connecting + } + + private waitForSocket(signal: AbortSignal): Promise { + signal.throwIfAborted() + if (this.socket?.readyState === WebSocket.OPEN) return Promise.resolve(this.socket) + if (this.disposed) return Promise.reject(new Error('api gateway: Remote stream client disposed')) + this.start() + return new Promise((resolve, reject) => { + const aborted = (): void => { waiter.reject(signal.reason) } + const cleanup = (): void => { + this.waiters.delete(waiter) + signal.removeEventListener('abort', aborted) + } + const waiter: SocketWaiter = { + resolve: (socket) => { + cleanup() + resolve(socket) + }, + reject: (error) => { + cleanup() + // AbortSignal.reason belongs to the caller and may intentionally be a non-Error sentinel. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + reject(error) + }, + } + this.waiters.add(waiter) + signal.addEventListener('abort', aborted, { once: true }) + }) + } + + private receive(socket: WebSocket, data: unknown): void { + if (socket !== this.socket) return + try { + if (typeof data !== 'string') throw new Error('api gateway: Remote stream WebSocket requires text messages') + const frame = parseRemoteStreamServerMessage(data) + this.streams.get(frame.streamId)?.push(frame) + } catch (error) { + const failure = new RemoteStreamCarrierError('api gateway: invalid Remote stream frame', { cause: error }) + this.failAll(failure) + this.lost(socket, failure) + socket.close(4002, 'invalid Remote stream frame') + } + } + + private lost( + socket: WebSocket, + error: RemoteStreamCarrierError = new RemoteStreamCarrierError( + 'api gateway: Remote stream WebSocket closed', + ), + ): void { + if (this.socket !== socket) return + this.socket = undefined + this.failAll(error) + this.maintain(error) + } + + private maintain(previousFailure?: Error): void { + if (!this.running) return + if (this.keepAlive !== undefined) { + void this.keepAlive.then(() => { this.maintain(previousFailure) }) + return + } + const abort = new AbortController() + this.keepAliveAbort = abort + const task = this.reconnect(abort.signal, previousFailure) + this.keepAlive = task + void task.then(() => { + this.keepAlive = undefined + this.keepAliveAbort = undefined + }) + } + + private async reconnect(signal: AbortSignal, previousFailure?: Error): Promise { + let attempt = 0 + let failure = previousFailure + while (this.isRunning(signal) && this.socket?.readyState !== WebSocket.OPEN) { + if (failure !== undefined) { + attempt += 1 + console.warn(`[api-gateway] Remote stream connection unavailable, retry #${String(attempt)}`, failure) + await sleep(backoffDelay(attempt), signal) + if (!this.isRunning(signal)) return + } + try { + await this.connect() + return + } catch (error) { + if (!this.isRunning(signal)) return + failure = error as Error + } + } + } + + private isRunning(signal: AbortSignal): boolean { + return this.running && !signal.aborted + } + + private failAll(error: unknown): void { + for (const stream of this.streams.values()) stream.fail(error) + } + + private send(socket: WebSocket, message: RemoteStreamClientMessage): void { + socket.send(JSON.stringify(message)) + } +} + +function backoffDelay(attempt: number): number { + const cap = Math.min(RECONNECT_MAX_MS, RECONNECT_BASE_MS * RECONNECT_FACTOR ** Math.max(0, attempt - 1)) + return cap / 2 + Math.random() * (cap / 2) +} + +function sleep(ms: number, signal: AbortSignal): Promise { + return new Promise((resolve) => { + const timer = setTimeout(done, ms) + signal.addEventListener('abort', done, { once: true }) + function done(): void { + clearTimeout(timer) + signal.removeEventListener('abort', done) + resolve() + } + }) +} + +class StreamInbox { + private readonly frames: RemoteStreamServerMessage[] = [] + private wake: (() => void) | undefined + private failure: Error | undefined + + push(frame: RemoteStreamServerMessage): void { + if (this.failure !== undefined) return + this.frames.push(frame) + this.wake?.() + this.wake = undefined + } + + fail(error: unknown): void { + if (this.failure !== undefined) return + this.failure = error instanceof Error ? error : new Error(String(error), { cause: error }) + this.frames.length = 0 + this.wake?.() + this.wake = undefined + } + + async next(): Promise { + while (this.frames.length === 0) { + if (this.failure !== undefined) throw this.failure + await new Promise((resolve) => { this.wake = resolve }) + } + return this.frames.shift() as RemoteStreamServerMessage + } +} + +function remoteStreamUrl(): string { + const location = (globalThis as { location?: { origin?: string } }).location + const base = location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE + const url = new URL(REMOTE_STREAM_MUX_PATH, base) + url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' + return url.href +} diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 9edb09d9b5..86a71d834a 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -1,14 +1,18 @@ /** * Live Typert Remote dispatch over Cordis Services and registered providers. - * Transport, request correlation, and response envelopes belong to Connection. + * Unary transport and response envelopes belong to Connection; live Remote + * streams use the Gateway-owned WebSocket mux. * @module @deepseek-ai/dsh-api-gateway */ +import { randomUUID } from 'node:crypto' import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' +import type { WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' import { remoteMethods, TypertLookupFailure, + TypertRemoteFailure, type InvocationDescriptor, type InvocationParameterDescriptor, type TypertCodec, @@ -18,12 +22,47 @@ import type { InvokeRemoteRequest, TypertGateway, TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, } from './types.ts' +import { + RemoteStreamMuxServer, + rejectRemoteStreamUpgrade, +} from './stream-server.ts' +import { + REMOTE_EVENT_STREAM_ENDPOINT, + REMOTE_EVENT_STREAM_READY, + REMOTE_EVENT_RESULT_ENDPOINT, + REMOTE_STREAM_MUX_PATH, + isRemoteEventAgentId, + isRemoteJsonValue, + parseRemoteEventResult, + projectRemoteEventRequest, + restoreRemoteEventRejection, + type RemoteEventCancellationFrame, + type RemoteEventClientId, + type RemoteEventEmitFrame, + type RemoteEventId, + type RemoteEventInvocationFrame, + type RemoteEventReadyFrame, + type RemoteStreamFailure, +} from './stream-protocol.ts' export type { InvokeRemoteRequest, TypertGateway, TypertGatewayErrorCode, + TypertGatewayWireStream, + TypertRemoteEventContext, + TypertRemoteEventDispatch, + TypertRemoteEventFrame, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, } from './types.ts' interface GatewayErrorOptions { @@ -36,6 +75,34 @@ interface ResolvedBinding { readonly original: object } +interface PreparedInvocation { + readonly endpoint: string + readonly descriptor: InvocationDescriptor + readonly receiver: object + readonly args: readonly unknown[] + readonly method: (...args: never[]) => unknown +} + +interface RegisteredRemoteEventSource { + readonly lifetime: AbortController + readonly done: Promise +} + +interface RemoteEventClient { + readonly id: RemoteEventClientId + readonly queue: RemoteEventQueue + readonly deliveries: Map +} + +interface PendingRemoteEvent { + readonly id: RemoteEventId + readonly source: TypertRemoteEventInvocation + readonly frame: RemoteEventInvocationFrame + readonly deliveries: Set + releaseContext: () => void + releaseSignal: () => void +} + type ConnectionRpcResult = Awaited> type ConnectionRpcError = Extract['error'] const NEVER_ABORTED_SIGNAL = new AbortController().signal @@ -90,7 +157,16 @@ class RemoteInvocationCancelled extends Error { export class TypertGatewayService extends Service implements TypertGateway { static inject = ['typert'] + /** Carrier adapter shared by the WebSocket mux and local Host transports. */ + readonly wireStream: TypertGatewayWireStream = { + open: (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + failure: error => rpcError(error), + } + private srcClaims: ReadonlySet | undefined + private remoteEvents: RegisteredRemoteEventSource | undefined + private readonly remoteEventClients = new Map() + private readonly pendingRemoteEvents = new Map() /** * Register the Gateway against the active Typert registry. @@ -106,12 +182,66 @@ export class TypertGatewayService extends Service implements TypertGateway { '/api', endpoint => this.claimsEndpoint(endpoint), (endpoint, payload, signal) => this.dispatchRpc(endpoint, payload, signal), - { authority: 'trusted-host' }, ) }) + ctx.inject(['connection', 'webServer'], (webCtx) => { + const mux = new RemoteStreamMuxServer( + (endpoint, payload, signal) => this.openWireStream(endpoint, payload, signal), + this.wireStream.failure, + ) + webCtx.effect(() => { + const route: WebUpgradeRoute = { + path: REMOTE_STREAM_MUX_PATH, + handler: (req, socket, head) => { + const rejection = webCtx.connection.requestRejection(req) + if (rejection !== undefined) { + rejectRemoteStreamUpgrade(socket, rejection) + return + } + mux.handleUpgrade(req, socket, head) + }, + } + const unregister = webCtx.webServer.registerUpgrade(route) + return async () => { + unregister() + await mux.close() + } + }, `api-gateway: ${REMOTE_STREAM_MUX_PATH} WebSocket`) + }) + } + + /** + * Register the sole application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise { + if (this.remoteEvents !== undefined) { + throw new Error('typert gateway: forwarded Remote event source is already registered') + } + const lifetime = new AbortController() + const stream = source(lifetime.signal) + const done = this.consumeRemoteEvents(stream, lifetime.signal).catch((error: unknown) => { + if (this.remoteEvents?.lifetime !== lifetime || lifetime.signal.aborted) return + this.closeRemoteEvents(error) + this.remoteEvents = undefined + lifetime.abort(error) + }) + const registration: RegisteredRemoteEventSource = { lifetime, done } + this.remoteEvents = registration + return async () => { + if (this.remoteEvents === registration) { + this.remoteEvents = undefined + const error = new Error('typert gateway: forwarded Remote event source was removed') + registration.lifetime.abort(error) + this.closeRemoteEvents(error) + } + await registration.done + } } private claimsEndpoint(endpoint: string): boolean { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) return true const segments = endpoint.split('/') if (segments.length !== 2 || segments[0] === '' || segments[1] === '') return false if (this.ctx.typert.local.get(endpoint) !== undefined || this.ctx.typert.local.hasSeen(endpoint)) return true @@ -139,10 +269,314 @@ export class TypertGatewayService extends Service implements TypertGateway { /** * Invoke one live Remote method through strict generated reflection or SRC markers. * @param request - decoded endpoint and exact named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ async invoke(request: InvokeRemoteRequest): Promise { + const prepared = await this.prepareInvocation(request) + if (prepared.descriptor.mode === 'stream') { + throw new TypertGatewayError( + 'signature-invalid', + prepared.endpoint, + 'stream Remote methods must be opened through the stream carrier', + ) + } + + try { + return await Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + throw error + } + } + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ + async stream(request: InvokeRemoteRequest): Promise> { + const prepared = await this.prepareInvocation(request) + if (prepared.descriptor.mode !== 'stream') { + throw new TypertGatewayError( + 'signature-invalid', + prepared.endpoint, + 'unary Remote methods cannot be opened through the stream carrier', + ) + } + let source: unknown + try { + source = Reflect.apply(prepared.method, prepared.receiver, prepared.args) as unknown + } catch (error) { + if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(prepared.endpoint, error) + throw error + } + if (!isIterable(source)) { + throw new TypertGatewayError( + 'result-invalid', + prepared.endpoint, + 'stream Remote method did not return Iterable or AsyncIterable', + { field: 'result' }, + ) + } + return cancellableStream( + source, + prepared.endpoint, + request.signal ?? NEVER_ABORTED_SIGNAL, + ) + } + + private async dispatchRpc( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): Promise { + if (endpoint === REMOTE_EVENT_RESULT_ENDPOINT) { + try { + const result = parseRemoteEventResultPayload(payload) + const client = this.remoteEventClients.get(result.clientId) + if (client === undefined) { + throw new Error('typert gateway: Remote event result identifies no active event stream') + } + this.receiveRemoteEventResult(client, result) + return { ok: true, value: undefined } + } catch (error) { + return rpcFailure(error) + } + } + return this.invokeRpc(endpoint, payload, signal) + } + + private async openWireStream( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ): Promise> { + if (endpoint === REMOTE_EVENT_STREAM_ENDPOINT) { + return this.openRemoteEvents(payload, signal) + } + return this.stream(remoteRequest(endpoint, payload, signal)) + } + + private async *openRemoteEvents( + payload: unknown, + signal: AbortSignal, + ): AsyncGenerator< + RemoteEventEmitFrame | RemoteEventInvocationFrame | RemoteEventCancellationFrame + | RemoteEventReadyFrame + > { + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args) + || Reflect.ownKeys(payload.args).length !== 0) { + throw new TypertGatewayError( + 'arguments-invalid', + REMOTE_EVENT_STREAM_ENDPOINT, + 'forwarded Remote event stream requires an empty args object', + ) + } + const registration = this.remoteEvents + if (registration === undefined) { + throw new TypertGatewayError( + 'service-unavailable', + REMOTE_EVENT_STREAM_ENDPOINT, + 'forwarded Remote event source is unavailable', + ) + } + const lifetime = AbortSignal.any([signal, registration.lifetime.signal]) + let clientId = randomUUID() as RemoteEventClientId + while (this.remoteEventClients.has(clientId)) clientId = randomUUID() as RemoteEventClientId + const client: RemoteEventClient = { + id: clientId, + queue: new RemoteEventQueue(), + deliveries: new Map(), + } + this.remoteEventClients.set(clientId, client) + for (const pending of this.pendingRemoteEvents.values()) this.deliverRemoteEvent(pending, client) + try { + yield { ...REMOTE_EVENT_STREAM_READY, clientId } + yield* client.queue.iterate(lifetime) + } finally { + this.removeRemoteEventClient(client) + } + } + + private async consumeRemoteEvents( + source: AsyncIterable, + signal: AbortSignal, + ): Promise { + for await (const dispatch of source) { + if (signal.aborted) { + if ('context' in dispatch) dispatch.reject(signal.reason) + return + } + if ('context' in dispatch) this.startRemoteEvent(dispatch) + else this.broadcastRemoteEvent(dispatch) + } + if (!signal.aborted) { + throw new Error('typert gateway: forwarded Remote event source ended unexpectedly') + } + } + + private broadcastRemoteEvent(frame: TypertRemoteEventFrame): void { + assertRemoteEventFrame(frame) + const wire: RemoteEventEmitFrame = { + type: 'emit', + event: frame.event, + args: frame.args, + } + for (const client of this.remoteEventClients.values()) client.queue.push(wire) + } + + private startRemoteEvent(source: TypertRemoteEventInvocation): void { + try { + assertRemoteEventName(source) + const context = this.ctx.typert.contexts.identifyHost(source.context.value) + if (context === undefined) { + source.resolve({ kind: 'next' }) + return + } + if (context.kind !== 'agent' || !isRemoteEventAgentId(context.identity)) { + throw new TypeError( + 'typert gateway: scoped Remote events require a non-empty Agent identity', + ) + } + const projected = projectRemoteEventRequest(source.request, source.context.subject) + let id = randomUUID() as RemoteEventId + while (this.pendingRemoteEvents.has(id)) id = randomUUID() as RemoteEventId + let releaseContext: () => void + try { + const dispose = source.context.value.effect( + () => () => { + this.cancelRemoteEvent( + pending, + new Error(`typert gateway: Remote event Context ${JSON.stringify(context.kind)} was released`), + ) + }, + `api-gateway: Remote event ${JSON.stringify(source.event)}`, + ) + releaseContext = () => { void dispose() } + } catch { + source.resolve({ kind: 'next' }) + return + } + const signals = new Set(projected.signal === undefined ? [] : [projected.signal]) + const abort = (): void => { + const reason = [...signals].find(signal => signal.aborted)?.reason as unknown + this.cancelRemoteEvent(pending, reason instanceof Error + ? reason + : new Error('typert gateway: Remote event was cancelled', { cause: reason })) + } + const pending: PendingRemoteEvent = { + id, + source, + frame: { + type: 'waterfall', + event: source.event, + eventId: id, + agentId: context.identity, + request: projected.request, + }, + deliveries: new Set(), + releaseContext, + releaseSignal: () => { + for (const signal of signals) signal.removeEventListener('abort', abort) + }, + } + this.pendingRemoteEvents.set(id, pending) + for (const signal of signals) signal.addEventListener('abort', abort, { once: true }) + if ([...signals].some(signal => signal.aborted)) abort() + else for (const client of this.remoteEventClients.values()) this.deliverRemoteEvent(pending, client) + } catch (error) { + source.reject(error) + } + } + + private deliverRemoteEvent(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.add(client) + client.deliveries.set(pending.id, pending) + client.queue.push(pending.frame) + } + + private receiveRemoteEventResult( + client: RemoteEventClient, + result: ReturnType, + ): void { + const pending = this.pendingRemoteEvents.get(result.eventId) + // Settlement and Client replacement may race the result request. Results + // from a completed event or a superseded delivery are idempotent no-ops. + if (pending === undefined || !pending.deliveries.has(client)) return + this.removeRemoteEventDelivery(pending, client) + if (result.outcome.kind === 'result') { + this.settleRemoteEvent(pending, { + kind: 'result', + value: result.outcome.value, + }) + } else if (result.outcome.kind === 'rejected') { + this.cancelRemoteEvent(pending, restoreRemoteEventRejection(result.outcome.error)) + } else if (pending.deliveries.size === 0) { + this.settleRemoteEvent(pending, { kind: 'next' }) + } + } + + private removeRemoteEventDelivery(pending: PendingRemoteEvent, client: RemoteEventClient): void { + pending.deliveries.delete(client) + client.deliveries.delete(pending.id) + } + + private removeRemoteEventClient(client: RemoteEventClient): void { + this.remoteEventClients.delete(client.id) + for (const pending of [...client.deliveries.values()]) this.removeRemoteEventDelivery(pending, client) + client.queue.end() + } + + private settleRemoteEvent(pending: PendingRemoteEvent, outcome: TypertRemoteEventOutcome): void { + this.finishRemoteEvent(pending) + pending.source.resolve(outcome) + } + + private cancelRemoteEvent(pending: PendingRemoteEvent, reason: unknown): void { + if (this.pendingRemoteEvents.get(pending.id) !== pending) return + this.finishRemoteEvent(pending) + pending.source.reject(reason) + } + + private finishRemoteEvent(pending: PendingRemoteEvent): void { + this.pendingRemoteEvents.delete(pending.id) + pending.releaseSignal() + pending.releaseContext() + const clients = new Set(pending.deliveries) + for (const client of clients) this.removeRemoteEventDelivery(pending, client) + const cancellation: RemoteEventCancellationFrame = { + type: 'cancel', + eventId: pending.id, + } + for (const client of clients) client.queue.push(cancellation) + } + + private closeRemoteEvents(reason: unknown): void { + for (const pending of [...this.pendingRemoteEvents.values()]) { + this.cancelRemoteEvent(pending, reason) + } + for (const client of [...this.remoteEventClients.values()]) client.queue.end() + } + + private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { + try { + const value = await this.invoke(remoteRequest(endpoint, payload, signal)) + // A void or explicitly absent business result carries no `value` field; + // JSON has no `undefined`, and the envelope's optional slot is the one + // representation of absence that both args and results already use. + return { ok: true, value } + } catch (error) { + return rpcFailure(error) + } + } + + private async prepareInvocation(request: InvokeRemoteRequest): Promise { const endpoint = endpointOf(request.namespace, request.method) const descriptor = this.resolveDescriptor(request.namespace, request.method, endpoint) assertExactArguments(request.args, descriptor, endpoint) @@ -168,57 +602,7 @@ export class TypertGatewayService extends Service implements TypertGateway { `active Service ${JSON.stringify(descriptor.service)} has no callable method ${JSON.stringify(implementation)}`, ) } - - let result: unknown - try { - result = await Reflect.apply(method, receiver, args) as unknown - } catch (error) { - if (request.signal?.aborted === true) throw new RemoteInvocationCancelled(endpoint, error) - throw error - } - // A weak descriptor declares no return type, so nothing returned is a void - // result and rides the wire as an absent value field. A strict descriptor - // keeps its schema: there, undefined has to be a declared result. - if (result === undefined && descriptor.result.mode !== 'strict') return result - return decode(descriptor.result, result, 'result-invalid', endpoint, 'result') - } - - private async dispatchRpc( - endpoint: string, - payload: unknown, - signal: AbortSignal, - ): Promise { - return this.invokeRpc(endpoint, payload, signal) - } - - private async invokeRpc(endpoint: string, payload: unknown, signal: AbortSignal): Promise { - try { - const segments = endpoint.split('/') - if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { - throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) - } - const [namespace, method] = segments as [string, string] - if (!isObject(payload) - || !isPlainObject(payload) - || Reflect.ownKeys(payload).length !== 1 - || !Object.hasOwn(payload, 'args') - || !isObject(payload.args) - || !isPlainObject(payload.args)) { - throw new Error('Remote payload must contain exactly one plain-object args field') - } - const value = await this.invoke({ - namespace, - method, - args: payload.args, - signal, - }) - // A void or explicitly absent business result carries no `value` field; - // JSON has no `undefined`, and the envelope's optional slot is the one - // representation of absence that both args and results already use. - return { ok: true, value } - } catch (error) { - return rpcFailure(error) - } + return { endpoint, descriptor, receiver, args, method: method as (...args: never[]) => unknown } } private resolveDescriptor(namespace: string, method: string, endpoint: string): InvocationDescriptor { @@ -349,6 +733,7 @@ export class TypertGatewayService extends Service implements TypertGateway { namespace: binding.namespace, method, ...(marker.method === method ? {} : { implementation: marker.method }), + ...(marker.mode === undefined ? {} : { mode: marker.mode }), invocation: receiver, parameters, ...(cancellation === undefined ? {} : { cancellation }), @@ -380,7 +765,7 @@ export class TypertGatewayService extends Service implements TypertGateway { { field: invocation.wire }, ) } - const identity = decode(invocation.codec, args[invocation.wire], 'input-invalid', endpoint, invocation.wire) + const identity = decode(invocation.codec, args[invocation.wire], endpoint, invocation.wire) let context: Context | undefined try { context = await provider.resolve(identity) @@ -414,7 +799,7 @@ export class TypertGatewayService extends Service implements TypertGateway { // still fails decode. Lookup ids are never omissible, so absence here only // ever belongs to a json parameter. if (!Object.hasOwn(args, parameter.wire)) return undefined - const value = decode(parameter.codec, args[parameter.wire], 'input-invalid', endpoint, parameter.wire) + const value = decode(parameter.codec, args[parameter.wire], endpoint, parameter.wire) if (parameter.source === 'json') return value const key = parameter.lookup /* v8 ignore next -- registry validation rejects strict descriptors without a key, and SRC derivation always supplies one. */ @@ -468,6 +853,120 @@ export class TypertGatewayService extends Service implements TypertGateway { } } +type RemoteEventWireFrame = + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + +/** Pull-driven queue owned by one connected Client event generation. */ +class RemoteEventQueue { + private readonly frames: RemoteEventWireFrame[] = [] + private waiter: (() => void) | undefined + private closed = false + + push(frame: RemoteEventWireFrame): void { + if (this.closed) return + this.frames.push(frame) + this.waiter?.() + } + + end(): void { + if (this.closed) return + this.closed = true + this.waiter?.() + } + + async *iterate(signal: AbortSignal): AsyncGenerator { + const abort = (): void => { this.end() } + signal.addEventListener('abort', abort, { once: true }) + try { + while (true) { + while (this.frames.length > 0) yield this.frames.shift() as RemoteEventWireFrame + if (this.closed || signal.aborted) return + await new Promise((resolve) => { this.waiter = resolve }) + this.waiter = undefined + } + } finally { + signal.removeEventListener('abort', abort) + } + } +} + +function assertRemoteEventFrame(frame: TypertRemoteEventFrame): void { + assertRemoteEventName(frame) + if (!Array.isArray(frame.args) || !isRemoteJsonValue(frame.args)) { + throw new TypeError(`typert gateway: Remote event ${JSON.stringify(frame.event)} arguments are not lossless JSON data`) + } +} + +function assertRemoteEventName(frame: { readonly event: unknown }): void { + if (typeof frame.event !== 'string' || frame.event.length === 0) { + throw new TypeError('typert gateway: Remote event name must be a nonempty string') + } +} + +function parseRemoteEventResultPayload(payload: unknown): ReturnType { + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args')) { + throw new Error('typert gateway: Remote event result requires exactly one plain-object args field') + } + return parseRemoteEventResult(payload.args) +} + +function remoteRequest(endpoint: string, payload: unknown, signal: AbortSignal): InvokeRemoteRequest { + const segments = endpoint.split('/') + if (segments.length !== 2 || segments[0] === '' || segments[1] === '') { + throw new Error(`invalid Remote endpoint ${JSON.stringify(endpoint)}`) + } + const [namespace, method] = segments as [string, string] + if (!isObject(payload) + || !isPlainObject(payload) + || Reflect.ownKeys(payload).length !== 1 + || !Object.hasOwn(payload, 'args') + || !isObject(payload.args) + || !isPlainObject(payload.args)) { + throw new Error('Remote payload must contain exactly one plain-object args field') + } + return { namespace, method, args: payload.args, signal } +} + +function isIterable(value: unknown): value is Iterable | AsyncIterable { + return isObject(value) + && (typeof Reflect.get(value, Symbol.iterator) === 'function' + || typeof Reflect.get(value, Symbol.asyncIterator) === 'function') +} + +async function *cancellableStream( + source: Iterable | AsyncIterable, + endpoint: string, + signal: AbortSignal, +): AsyncGenerator { + const asyncFactory = Reflect.get(source, Symbol.asyncIterator) as unknown + const syncFactory = Reflect.get(source, Symbol.iterator) as unknown + const iterator = typeof asyncFactory === 'function' + ? Reflect.apply(asyncFactory, source, []) as AsyncIterator + : Reflect.apply(syncFactory as (...args: never[]) => Iterator, source, []) + let rejectAbort: ((error: unknown) => void) | undefined + const aborted = new Promise((_resolve, reject) => { rejectAbort = reject }) + const onAbort = (): void => { + rejectAbort?.(new RemoteInvocationCancelled(endpoint, signal.reason)) + } + signal.addEventListener('abort', onAbort, { once: true }) + try { + if (signal.aborted) throw new RemoteInvocationCancelled(endpoint, signal.reason) + while (true) { + const next = await Promise.race([Promise.resolve(iterator.next()), aborted]) + if (next.done === true) return + yield next.value + } + } finally { + signal.removeEventListener('abort', onAbort) + await iterator.return?.() + } +} + function rpcFailure(error: unknown): ConnectionRpcResult { if (error instanceof RemoteInvocationCancelled) { return { @@ -478,6 +977,9 @@ function rpcFailure(error: unknown): ConnectionRpcResult { if (error instanceof TypertLookupFailure) { return { ok: false, error: error.failure as ConnectionRpcError } } + if (error instanceof TypertRemoteFailure) { + return { ok: false, error: error.failure } + } return { ok: false, error: { @@ -488,6 +990,10 @@ function rpcFailure(error: unknown): ConnectionRpcResult { } } +function rpcError(error: unknown): ConnectionRpcError & RemoteStreamFailure { + return (rpcFailure(error) as Extract).error +} + function endpointOf(namespace: string, method: string): string { return `${namespace}/${method}` } @@ -614,24 +1120,22 @@ function assertExactArguments( function decode( codec: TypertCodec, value: unknown, - code: 'input-invalid' | 'result-invalid', endpoint: string, field: string, ): unknown { try { if (codec.mode === 'strict') { value = codec.schema.parse(value) + /* v8 ignore next -- generated optional-input codecs are the only strict codecs that return undefined. */ if (value === undefined) return value } assertJsonValue(value, new Set()) return value } catch (cause) { throw new TypertGatewayError( - code, + 'input-invalid', endpoint, - code === 'input-invalid' - ? `wire field ${JSON.stringify(field)} failed boundary validation` - : 'business result failed boundary validation', + `wire field ${JSON.stringify(field)} failed boundary validation`, { cause, field }, ) } diff --git a/packages/api/gateway/src/stream-protocol.ts b/packages/api/gateway/src/stream-protocol.ts new file mode 100644 index 0000000000..2c6e8ebf70 --- /dev/null +++ b/packages/api/gateway/src/stream-protocol.ts @@ -0,0 +1,399 @@ +/** Wire messages for Gateway-owned Remote streams and event-result RPCs. */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** Exact WebSocket route carrying every Typert Remote stream. */ +export const REMOTE_STREAM_MUX_PATH = '/api/remote.mux' + +/** Gateway-internal logical stream carrying application-selected Cordis events. */ +export const REMOTE_EVENT_STREAM_ENDPOINT = '$events' + +/** Gateway-internal unary endpoint returning one Client Remote Event outcome. */ +export const REMOTE_EVENT_RESULT_ENDPOINT = '$events/result' + +/** Empty standard Remote payload used to open the forwarded-event stream. */ +export const REMOTE_EVENT_STREAM_PAYLOAD = { args: {} } as const + +/** Discriminator for the first item proving the Host event source is ready. */ +export const REMOTE_EVENT_STREAM_READY = { type: 'ready' } as const + +/** Opaque identity for one active Client Remote Event generation. */ +export type RemoteEventClientId = Branded<'RemoteEventClientId'> + +/** Opaque correlation id for one pending Host-to-Client Remote Event. */ +export type RemoteEventId = Branded<'RemoteEventId'> + +/** Opening item that binds later HTTP results to this active event stream. */ +export interface RemoteEventReadyFrame { + readonly type: 'ready' + readonly clientId: RemoteEventClientId +} + +/** Opaque Agent identity carried by one scoped Remote Event. */ +export type RemoteEventAgentId = Branded<'RemoteEventAgentId'> + +/** One Host notification delivered to a Client generation. */ +export interface RemoteEventEmitFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +/** One pending Agent-scoped waterfall delivered to a Client generation. */ +export interface RemoteEventInvocationFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: RemoteEventId + readonly agentId: RemoteEventAgentId + readonly request: Readonly> +} + +/** Cancellation of a pending waterfall previously delivered under the same id. */ +export interface RemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: RemoteEventId +} + +/** Every item carried by the Gateway-internal forwarded-event stream. */ +export type RemoteEventDownlinkFrame = + | RemoteEventReadyFrame + | RemoteEventEmitFrame + | RemoteEventInvocationFrame + | RemoteEventCancellationFrame + +/** JSON request fields plus the Host cancellation lifetime removed for transport. */ +export interface ProjectedRemoteEventRequest { + readonly request: Readonly> + readonly signal?: AbortSignal +} + +/** Error fields retained when a Client listener rejects a Host waterfall. */ +export interface RemoteEventRejection { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown +} + +/** Client response to one scoped Remote Event delivery. */ +export interface RemoteEventResult { + readonly clientId: RemoteEventClientId + readonly eventId: RemoteEventId + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { readonly kind: 'rejected'; readonly error: RemoteEventRejection } +} + +/** + * Parse one result sent through the Client's `$events/result` HTTP RPC. + * @param value - untrusted result payload. + * @returns validated event correlation and outcome fields. + */ +export function parseRemoteEventResult(value: unknown): RemoteEventResult { + if (!isRecord(value) + || !exactKeys(value, ['clientId', 'eventId', 'outcome']) + || !isRemoteEventClientId(value.clientId) + || !isRemoteEventId(value.eventId) + || !isRecord(value.outcome)) { + throw new Error('api gateway: invalid Remote event result') + } + const outcome = value.outcome + if (outcome.kind === 'next' && exactKeys(outcome, ['kind'])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: 'next' }, + } + } + if (outcome.kind === 'result' + && (exactKeys(outcome, ['kind']) || exactKeys(outcome, ['kind', 'value'])) + && (!Object.hasOwn(outcome, 'value') || isRemoteJsonValue(outcome.value))) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: Object.hasOwn(outcome, 'value') + ? { kind: 'result', value: outcome.value } + : { kind: 'result' }, + } + } + if (outcome.kind === 'rejected' + && exactKeys(outcome, ['kind', 'error'])) { + return { + clientId: value.clientId, + eventId: value.eventId, + outcome: { kind: 'rejected', error: parseRemoteEventRejection(outcome.error) }, + } + } + throw new Error('api gateway: invalid Remote event result') +} + +/** + * Remove the direct Agent and cancellation fields from one waterfall request. + * @param value - request object before the waterfall's `next` callback. + * @param subject - Agent used by the Cordis scope carrier. + * @returns JSON-safe request fields and the optional Host cancellation signal. + */ +export function projectRemoteEventRequest( + value: unknown, + subject: object, +): ProjectedRemoteEventRequest { + if (!isPlainRecord(value) || !Object.hasOwn(value, 'agent') || value.agent !== subject) { + throw new TypeError('api gateway: Remote event request must carry its scoped Agent directly') + } + const signal = value.signal + if (signal !== undefined && !(signal instanceof AbortSignal)) { + throw new TypeError('api gateway: Remote event request signal must be an AbortSignal') + } + const request: Record = Object.create(null) as Record + for (const key of Reflect.ownKeys(value)) { + if (key === 'agent' || key === 'signal') continue + const descriptor = typeof key === 'string' ? Object.getOwnPropertyDescriptor(value, key) : undefined + if (typeof key !== 'string' || descriptor?.enumerable !== true) { + throw new TypeError('api gateway: Remote event request has a non-JSON property') + } + request[key] = Reflect.get(value, key) + } + if (!isRemoteJsonValue(request)) { + throw new TypeError('api gateway: Remote event request is not lossless JSON data') + } + return { + request, + ...(signal === undefined ? {} : { signal }), + } +} + +/** + * Project an arbitrary rejection to stable, JSON-safe error fields. + * @param reason - value thrown or rejected by a Client listener. + * @returns wire-safe rejection fields. + */ +export function projectRemoteEventRejection(reason: unknown): RemoteEventRejection { + const record = typeof reason === 'object' && reason !== null ? reason : undefined + const name = stringProperty(record, 'name') ?? 'Error' + const message = stringProperty(record, 'message') ?? String(reason) + const code = stringProperty(record, 'code') + const details = record === undefined ? undefined : Reflect.get(record, 'details') as unknown + return { + name, + message, + ...(code === undefined ? {} : { code }), + ...(details === undefined || !isRemoteJsonValue(details) ? {} : { details }), + } +} + +/** + * Recreate a Client rejection for the Host continuation. + * @param rejection - validated wire-safe error fields. + * @returns an Error preserving the remote name, code, and JSON-safe details. + */ +export function restoreRemoteEventRejection(rejection: RemoteEventRejection): Error { + const error = new Error(rejection.message) as Error & { code?: string; details?: unknown } + error.name = rejection.name + if (rejection.code !== undefined) error.code = rejection.code + if (rejection.details !== undefined) error.details = rejection.details + return error +} + +/** + * Test whether a value crosses JSON transport without coercion or omission. + * @param value - candidate boundary value. + * @returns whether the value is losslessly JSON-compatible. + */ +export function isRemoteJsonValue(value: unknown): boolean { + return visitJsonValue(value, new Set()) +} + +/** + * Recognize a non-empty Remote Event correlation id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value is a valid Remote Event id. + */ +export function isRemoteEventId(value: unknown): value is RemoteEventId { + return typeof value === 'string' && value.length > 0 +} + +/** + * Recognize a non-empty Remote Event Client id at a wire boundary. + * @param value - untrusted wire value. + * @returns whether the value identifies one event-stream generation. + */ +export function isRemoteEventClientId(value: unknown): value is RemoteEventClientId { + return typeof value === 'string' && value.length > 0 +} + +/** + * Recognize the direct Agent identity used by a scoped Remote Event. + * @param value - untrusted wire value. + * @returns whether the value is a non-empty Agent identity. + */ +export function isRemoteEventAgentId(value: unknown): value is RemoteEventAgentId { + return typeof value === 'string' && value.length > 0 +} + +/** One logical stream request sent from the browser. */ +export type RemoteStreamClientMessage = + | { + readonly type: 'open' + readonly streamId: string + readonly endpoint: string + readonly payload: unknown + } + | { readonly type: 'cancel'; readonly streamId: string } + +/** Carrier-safe failure delivered by the Host. */ +export interface RemoteStreamFailure { + readonly code: string + readonly message: string + readonly details: object +} + +/** One logical stream frame sent from the Host. */ +export type RemoteStreamServerMessage = + | { readonly type: 'item'; readonly streamId: string; readonly value?: unknown } + | { readonly type: 'error'; readonly streamId: string; readonly error: RemoteStreamFailure } + | { readonly type: 'end'; readonly streamId: string } + +/** + * Parse and validate one browser-to-Host text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream request. + */ +export function parseRemoteStreamClientMessage(text: string): RemoteStreamClientMessage { + return parseMessage(text, (value) => { + if (value.type === 'cancel' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + return value as unknown as RemoteStreamClientMessage + } + if (value.type === 'open' + && exactKeys(value, ['type', 'streamId', 'endpoint', 'payload']) + && validId(value.streamId) + && typeof value.endpoint === 'string' + && value.endpoint.length > 0) { + return value as unknown as RemoteStreamClientMessage + } + throw new Error('api gateway: invalid Remote stream client message') + }) +} + +/** + * Parse and validate one Host-to-browser text message. + * @param text - complete WebSocket text message. + * @returns the validated logical-stream frame. + */ +export function parseRemoteStreamServerMessage(text: string): RemoteStreamServerMessage { + return parseMessage(text, (value) => { + if (value.type === 'item' + && (exactKeys(value, ['type', 'streamId']) || exactKeys(value, ['type', 'streamId', 'value'])) + && validId(value.streamId)) { + return value as unknown as RemoteStreamServerMessage + } + if (value.type === 'end' && exactKeys(value, ['type', 'streamId']) && validId(value.streamId)) { + return value as unknown as RemoteStreamServerMessage + } + if (value.type === 'error' + && exactKeys(value, ['type', 'streamId', 'error']) + && validId(value.streamId) + && isRecord(value.error) + && exactKeys(value.error, ['code', 'message', 'details']) + && typeof value.error.code === 'string' + && typeof value.error.message === 'string' + && isRecord(value.error.details)) { + return value as unknown as RemoteStreamServerMessage + } + throw new Error('api gateway: invalid Remote stream server message') + }) +} + +function parseMessage(text: string, validate: (value: Record) => T): T { + let decoded: unknown + try { + decoded = JSON.parse(text) as unknown + } catch (cause) { + throw new Error('api gateway: Remote stream message is not JSON', { cause }) + } + if (!isRecord(decoded)) throw new Error('api gateway: Remote stream message must be an object') + return validate(decoded) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' + && value !== null + && !Array.isArray(value) +} + +function isPlainRecord(value: unknown): value is Record { + if (!isRecord(value)) return false + const prototype: unknown = Object.getPrototypeOf(value) + return prototype === Object.prototype || prototype === null +} + +function exactKeys(value: Record, expected: readonly string[]): boolean { + const keys = Reflect.ownKeys(value) + return keys.length === expected.length && expected.every(key => Object.hasOwn(value, key)) +} + +function validId(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function parseRemoteEventRejection(value: unknown): RemoteEventRejection { + if (!isRecord(value) + || !hasOnlyKeys(value, ['name', 'message'], ['code', 'details']) + || typeof value.name !== 'string' + || value.name.length === 0 + || typeof value.message !== 'string' + || (Object.hasOwn(value, 'code') && typeof value.code !== 'string') + || (Object.hasOwn(value, 'details') && !isRemoteJsonValue(value.details))) { + throw new Error('api gateway: invalid Remote event rejection') + } + return { + name: value.name, + message: value.message, + ...(typeof value.code === 'string' ? { code: value.code } : {}), + ...(Object.hasOwn(value, 'details') ? { details: value.details } : {}), + } +} + +function hasOnlyKeys( + value: Record, + required: readonly string[], + optional: readonly string[], +): boolean { + const keys = Reflect.ownKeys(value) + return required.every(key => Object.hasOwn(value, key)) + && keys.every(key => typeof key === 'string' && (required.includes(key) || optional.includes(key))) +} + +function stringProperty(value: object | undefined, key: string): string | undefined { + if (value === undefined) return undefined + const candidate: unknown = Reflect.get(value, key) + return typeof candidate === 'string' ? candidate : undefined +} + +function visitJsonValue(value: unknown, ancestors: Set): boolean { + if (value === null || typeof value === 'string' || typeof value === 'boolean') return true + if (typeof value === 'number') return Number.isFinite(value) && !Object.is(value, -0) + if (typeof value !== 'object') return false + if (ancestors.has(value)) return false + ancestors.add(value) + try { + if (Array.isArray(value)) { + if (Object.getPrototypeOf(value) !== Array.prototype + || Reflect.ownKeys(value).length !== value.length + 1) return false + for (let index = 0; index < value.length; index++) { + if (!Object.hasOwn(value, index) || !visitJsonValue(value[index], ancestors)) return false + } + return true + } + const prototype: unknown = Object.getPrototypeOf(value) + if (prototype !== Object.prototype && prototype !== null) return false + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== 'string') return false + const descriptor = Object.getOwnPropertyDescriptor(value, key) + if (descriptor?.enumerable !== true || !visitJsonValue(Reflect.get(value, key), ancestors)) return false + } + return true + } finally { + ancestors.delete(value) + } +} diff --git a/packages/api/gateway/src/stream-server.ts b/packages/api/gateway/src/stream-server.ts new file mode 100644 index 0000000000..04b0df0427 --- /dev/null +++ b/packages/api/gateway/src/stream-server.ts @@ -0,0 +1,191 @@ +/** Host WebSocket owner for multiplexed Typert Remote streams. */ + +import type { IncomingMessage } from 'node:http' +import type { Duplex } from 'node:stream' +import WebSocket, { WebSocketServer, type RawData } from 'ws' +import { + parseRemoteStreamClientMessage, + type RemoteStreamFailure, + type RemoteStreamServerMessage, +} from './stream-protocol.ts' + +/** Open one validated Remote stream for a decoded wire request. */ +export type RemoteStreamOpener = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => Promise> + +/** Convert an invocation or carrier failure to a stable wire value. */ +export type RemoteStreamFailureMapper = (error: unknown) => RemoteStreamFailure + +/** Own the no-server WebSocket acceptor and every active logical stream. */ +export class RemoteStreamMuxServer { + private readonly server = new WebSocketServer({ noServer: true }) + private readonly connections = new Set>() + + /** + * @param open - Gateway stream dispatcher. + * @param failure - Gateway error-to-wire mapper. + */ + constructor( + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + ) {} + + /** + * Upgrade one trusted request and begin serving its logical streams. + * @param req - authenticated HTTP upgrade request. + * @param socket - carrier socket transferred to the WebSocket server. + * @param head - bytes already read after the HTTP upgrade headers. + */ + handleUpgrade(req: IncomingMessage, socket: Duplex, head: Buffer): void { + this.server.handleUpgrade(req, socket, head, (websocket) => { + const connection = new RemoteStreamMuxConnection(websocket, this.open, this.failure) + const done = connection.run() + this.connections.add(done) + void done.then(() => { this.connections.delete(done) }) + }) + } + + /** Terminate all sockets and wait until every iterator has returned. */ + async close(): Promise { + for (const socket of this.server.clients) socket.terminate() + const closed = Promise.withResolvers() + this.server.close((error) => { + if (error === undefined) closed.resolve() + else closed.reject(error) + }) + await closed.promise + await Promise.all(this.connections) + } +} + +interface ActiveStream { + readonly abort: AbortController + done: Promise +} + +class RemoteStreamMuxConnection { + private readonly streams = new Map() + private writes = Promise.resolve() + + constructor( + private readonly socket: WebSocket, + private readonly open: RemoteStreamOpener, + private readonly failure: RemoteStreamFailureMapper, + ) {} + + async run(): Promise { + const closed = new Promise((resolve) => { + this.socket.once('close', resolve) + this.socket.once('error', () => { this.socket.terminate() }) + this.socket.on('message', (data, isBinary) => { + if (isBinary) { + this.socket.close(1003, 'text messages required') + return + } + try { + this.receive(rawText(data)) + } catch { + this.socket.close(1008, 'invalid Remote stream request') + } + }) + }) + await closed + const active = [...this.streams.values()] + for (const stream of active) stream.abort.abort(new Error('Remote stream socket closed')) + await Promise.all(active.map(stream => stream.done)) + } + + private receive(text: string): void { + const message = parseRemoteStreamClientMessage(text) + if (message.type === 'cancel') { + this.streams.get(message.streamId)?.abort.abort(new Error('Remote stream cancelled')) + return + } + if (this.streams.has(message.streamId)) { + throw new Error(`api gateway: duplicate Remote stream id ${JSON.stringify(message.streamId)}`) + } + const abort = new AbortController() + const active: ActiveStream = { + abort, + done: Promise.resolve(), + } + this.streams.set(message.streamId, active) + const done = this.pump(message.streamId, message.endpoint, message.payload, active) + active.done = done + const remove = (): void => { this.streams.delete(message.streamId) } + void done.then(remove, remove) + } + + private async pump( + streamId: string, + endpoint: string, + payload: unknown, + active: ActiveStream, + ): Promise { + try { + const source = await this.open(endpoint, payload, active.abort.signal) + for await (const value of source) { + await this.send({ type: 'item', streamId, value }) + } + if (!active.abort.signal.aborted) await this.send({ type: 'end', streamId }) + } catch (error) { + if (!active.abort.signal.aborted && this.socket.readyState === WebSocket.OPEN) { + try { + await this.send({ type: 'error', streamId, error: this.failure(error) }) + } catch { + // A terminal frame that cannot be encoded or written leaves the + // logical stream ambiguous, so fail the physical generation. + this.socket.close(1011, 'Remote stream failure could not be delivered') + } + } + } + } + + private send(message: RemoteStreamServerMessage): Promise { + let text: string + try { + text = JSON.stringify(message) + } catch (cause) { + return Promise.reject(new Error('api gateway: Remote stream item is not JSON serializable', { cause })) + } + const delivery = this.writes.then(() => new Promise((resolve, reject) => { + if (this.socket.readyState !== WebSocket.OPEN) { + reject(new Error('api gateway: Remote stream socket is closed')) + return + } + this.socket.send(text, (error) => { + if (error) reject(error) + else resolve() + }) + })) + this.writes = delivery.catch(() => undefined) + return delivery + } +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +/** + * Reject an upgrade without transferring socket ownership to ws. + * @param socket - carrier socket that receives the HTTP rejection. + * @param status - authentication or browser-trust rejection status. + */ +export function rejectRemoteStreamUpgrade(socket: Duplex, status: 401 | 403): void { + const reason = status === 401 ? 'Unauthorized' : 'Forbidden' + const body = reason.toLowerCase() + socket.end([ + `HTTP/1.1 ${String(status)} ${reason}`, + 'Connection: close', + 'Content-Type: text/plain; charset=utf-8', + `Content-Length: ${String(Buffer.byteLength(body))}`, + '', + body, + ].join('\r\n')) +} diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 581e1aa2ce..b41f35e905 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -3,6 +3,8 @@ * @module @deepseek-ai/dsh-api-gateway/types */ +import type { Context } from '@deepseek-ai/cordis' + /** One Remote method request after a carrier has decoded its envelope. */ export interface InvokeRemoteRequest { /** Remote namespace selected by the generated descriptor. */ @@ -15,6 +17,85 @@ export interface InvokeRemoteRequest { readonly signal?: AbortSignal } +/** One Host Cordis notification forwarded unchanged to Client Remote subscribers. */ +export interface TypertRemoteEventFrame { + /** Original Host Cordis event name. */ + readonly event: string + /** Original event argument list after the owner validates it for JSON transport. */ + readonly args: readonly unknown[] +} + +/** Live Host values used to project one scoped Remote Event. */ +export interface TypertRemoteEventContext { + /** Live Host Context identified by the registered Host adapters. */ + readonly value: Context + /** Agent object carried directly by the waterfall request. */ + readonly subject: object +} + +/** Result returned from a Client waterfall, or delegation back to the Host chain. */ +export type TypertRemoteEventOutcome = + | { readonly kind: 'result'; readonly value: unknown } + | { readonly kind: 'next' } + +/** + * One scoped waterfall invocation yielded by the application event source. + * The Gateway alone assigns transport ids and resolves the continuation after + * a Client result or explicit delegation. + */ +export interface TypertRemoteEventInvocation { + /** Original Host Cordis event name. */ + readonly event: string + /** Sole request argument before the waterfall's `next()` callback. */ + readonly request: object + readonly context: TypertRemoteEventContext + /** Resume the source's Cordis listener with a Client result or `next()`. */ + readonly resolve: (outcome: TypertRemoteEventOutcome) => void + /** Reject the source's Cordis listener after cancellation, transport failure, or Client rejection. */ + readonly reject: (reason: unknown) => void +} + +/** Notification or scoped waterfall accepted from the sole Remote Event source. */ +export type TypertRemoteEventDispatch = TypertRemoteEventFrame | TypertRemoteEventInvocation + +/** + * Open the application-selected event stream for one Client carrier. The + * factory must attach all incremental Host listeners before it returns; the + * Gateway publishes its readiness item immediately afterward. + * @param signal - cancellation shared with the Client stream and registration. + * @returns the long-lived stream of notifications and scoped waterfall invocations. + */ +export type TypertRemoteEventSource = ( + signal: AbortSignal, +) => AsyncIterable + +/** Carrier-facing access to decoded Remote streams and their stable failures. */ +export interface TypertGatewayWireStream { + /** + * Open one logical stream from its wire endpoint and payload. + * @param endpoint - canonical Remote endpoint or Gateway-owned stream name. + * @param payload - decoded carrier payload. + * @param signal - logical-stream cancellation. + * @returns validated stream values. + */ + readonly open: ( + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => Promise> + + /** + * Convert a stream failure to the carrier-safe Remote failure fields. + * @param error - failure raised while opening or consuming a stream. + * @returns stable code, message, and details for the Client. + */ + readonly failure: (error: unknown) => { + readonly code: string + readonly message: string + readonly details: object + } +} + /** Stable infrastructure and boundary failures emitted before or after business execution. */ export type TypertGatewayErrorCode = | 'ambiguous-endpoint' @@ -37,13 +118,30 @@ export type TypertGatewayErrorCode = /** Host dispatcher consumed by Connection adapters. */ export interface TypertGateway { + /** Carrier adapter shared by WebSocket and in-process transports. */ + readonly wireStream: TypertGatewayWireStream + + /** + * Register the application-selected forwarded-event source. + * @param source - stream factory installed by the Remote assembly. + * @returns disposer removing this exact source and cancelling its active streams. + */ + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise + /** * Invoke one live Remote method without assuming a carrier or response envelope. * @param request - decoded endpoint and named wire arguments. - * @returns the validated business result. + * @returns the business result without output decoding. * @throws {@link TypertGatewayError} for dispatch, provider, or boundary failures; lookup-policy and business errors retain identity. */ invoke(request: InvokeRemoteRequest): Promise + + /** + * Open one live stream Remote method without assuming a physical carrier. + * @param request - decoded endpoint and named wire arguments. + * @returns a cancellation-aware iterable over the business results. + */ + stream(request: InvokeRemoteRequest): Promise> } declare module '@deepseek-ai/cordis' { diff --git a/packages/api/gateway/tests/browser-credentials.ts b/packages/api/gateway/tests/browser-credentials.ts new file mode 100644 index 0000000000..8661983ea8 --- /dev/null +++ b/packages/api/gateway/tests/browser-credentials.ts @@ -0,0 +1,17 @@ +import type { Context } from '@deepseek-ai/cordis' + +/** Provide an in-memory credential-record owner for a mounted Connection plugin. */ +export function provideBrowserCredentials(ctx: Context): void { + const records = new Map() + ctx.provide('credentials', { + async modifyRecord( + key: unknown, + mutate: (current: unknown) => Promise, + ): Promise { + const current = records.get(key) + const next = await mutate(current) + if (next !== undefined) records.set(key, next) + return next ?? current + }, + } as never) +} diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts new file mode 100644 index 0000000000..a278cc6acd --- /dev/null +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -0,0 +1,347 @@ +import { describe, expect, it, vi } from 'vitest' +import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' +import { + RemoteStreamCarrierError, + RemoteStream, +} from '../src/client/index.ts' + +const DESCRIPTION = { + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, +} + +function hostSource(initiallyAvailable: boolean): { + connection: Pick + publish(available: boolean): void +} { + let current = initiallyAvailable ? DESCRIPTION : undefined + const listeners = new Set<() => void>() + return { + connection: { + hostDescription: { + getSnapshot: () => current, + subscribe: (listener) => { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + }, + publish: (available) => { + current = available ? DESCRIPTION : undefined + for (const listener of listeners) listener() + }, + } +} + +interface Generation { + readonly values?: readonly (Item | Promise)[] + readonly terminal?: Error + readonly hold?: boolean + readonly afterAbortError?: Error + readonly close?: () => Promise +} + +function scripted(generations: Generation[], opened?: () => void) { + return (signal: AbortSignal): AsyncIterable => ({ + async * [Symbol.asyncIterator](): AsyncIterator { + const generation = generations.shift() + if (generation === undefined) throw new Error('fixture has no stream generation') + opened?.() + try { + for (const value of generation.values ?? []) yield await value + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + if (generation.afterAbortError !== undefined) throw generation.afterAbortError + } finally { + await generation.close?.() + } + }, + }) +} + +function supervisor( + connection: Pick, + generations: Generation[], + carrierFailed?: (error: RemoteStreamCarrierError) => void, +): RemoteStream { + return new RemoteStream(connection, { + name: 'fixture stream', + open: scripted(generations), + ended: accepted => accepted + ? new RemoteStreamCarrierError('accepted generation ended') + : new Error('generation ended before acceptance'), + ...(carrierFailed === undefined ? {} : { carrierFailed }), + }) +} + +describe('RemoteStream', () => { + it('annotates replacement generations and resets retry state after acceptance', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first'], terminal: new RemoteStreamCarrierError('first lost') }, + { values: ['second'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { generation: 1, value: 'first' } }) + if (first.done) throw new Error('fixture generation ended early') + first.value.accept() + const second = await iterator.next() + expect(second).toMatchObject({ done: false, value: { generation: 2, value: 'second' } }) + if (second.done) throw new Error('fixture replacement ended early') + second.value.accept() + + await stream.dispose() + }) + + it('permits one isolated retry while the Host remains available', async () => { + const source = hostSource(true) + const first = new RemoteStreamCarrierError('first carrier failure') + const repeated = new RemoteStreamCarrierError('isolated retry failed') + const carrierFailed = vi.fn<(error: RemoteStreamCarrierError) => void>() + const stream = supervisor(source.connection, [ + { terminal: first }, + { terminal: repeated }, + ], carrierFailed) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated) + expect(carrierFailed).toHaveBeenNthCalledWith(1, first) + expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) + }) + + it('waits for a replacement Host generation after observing unavailability', async () => { + let available = false + let listener: (() => void) | undefined + const subscribed = Promise.withResolvers() + const connection = { + hostDescription: { + getSnapshot: () => available ? DESCRIPTION : undefined, + subscribe: (value: () => void) => { + listener = value + subscribed.resolve(undefined) + return () => { listener = undefined } + }, + }, + } + let opened = 0 + const stream = new RemoteStream(connection, { + name: 'fixture stream', + open: scripted([ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ], () => { opened++ }), + ended: () => new Error('ended'), + }) + const pending = stream[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(opened).toBe(1) }) + await subscribed.promise + + listener?.() + expect(opened).toBe(1) + available = true + listener?.() + await expect(pending).resolves.toMatchObject({ + done: false, + value: { generation: 2, value: 'ready' }, + }) + await stream.dispose() + }) + + it('stops a pending retry when the logical stream is disposed', async () => { + const source = hostSource(false) + let opened = 0 + const stream = new RemoteStream(source.connection, { + name: 'fixture stream', + open: scripted([ + { terminal: new RemoteStreamCarrierError('offline') }, + ], () => { opened++ }), + ended: () => new Error('ended'), + }) + const pending = stream[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(opened).toBe(1) }) + source.publish(false) + + await stream.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + }) + + it('contains a Host publication during subscription setup', async () => { + let reads = 0 + let disposed = 0 + const connection = { + hostDescription: { + getSnapshot: () => reads++ === 0 ? undefined : DESCRIPTION, + subscribe: (listener: () => void) => { + listener() + return () => { disposed++ } + }, + }, + } + const stream = supervisor(connection, [ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ]) + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(disposed).toBe(1) + await stream.dispose() + }) + + it('restarts with a fresh physical generation', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first'], hold: true }, + { values: ['second'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 1, value: 'first' } }) + + stream.restart() + + await expect(iterator.next()).resolves.toMatchObject({ value: { generation: 2, value: 'second' } }) + await stream.dispose() + }) + + it('drops values and cancellation failures from a replaced generation', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [ + { values: ['first', 'stale'] }, + { + values: ['second'], + hold: true, + afterAbortError: new Error('replaced generation cancelled'), + }, + { values: ['third'], hold: true }, + ]) + const iterator = stream[Symbol.asyncIterator]() + const first = await iterator.next() + if (first.done) throw new Error('fixture generation ended early') + + stream.restart() + first.value.accept() + await expect(iterator.next()).resolves.toMatchObject({ + value: { generation: 2, value: 'second' }, + }) + + stream.restart() + await expect(iterator.next()).resolves.toMatchObject({ + value: { generation: 3, value: 'third' }, + }) + await stream.dispose() + }) + + it('honors replacement requested by carrier diagnostics', async () => { + const source = hostSource(true) + const holder: { stream?: RemoteStream } = {} + const carrierFailed = vi.fn(() => { holder.stream?.restart() }) + const stream = supervisor(source.connection, [ + { terminal: new RemoteStreamCarrierError('replace this generation') }, + { values: ['ready'], hold: true }, + ], carrierFailed) + holder.stream = stream + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(carrierFailed).toHaveBeenCalledOnce() + await stream.dispose() + }) + + it('contains replacement during Host-readiness subscription setup', async () => { + const holder: { stream?: RemoteStream } = {} + let subscriptions = 0 + const connection = { + hostDescription: { + getSnapshot: () => undefined, + subscribe: () => { + subscriptions++ + holder.stream?.restart() + return () => {} + }, + }, + } + const stream = supervisor(connection, [ + { terminal: new RemoteStreamCarrierError('offline') }, + { values: ['ready'], hold: true }, + ]) + holder.stream = stream + + await expect(stream[Symbol.asyncIterator]().next()).resolves.toMatchObject({ + value: { generation: 2, value: 'ready' }, + }) + expect(subscriptions).toBe(1) + await stream.dispose() + }) + + it('waits for generation cleanup during disposal', async () => { + const source = hostSource(true) + const release = Promise.withResolvers() + let closed = false + const stream = supervisor(source.connection, [{ + values: ['ready'], + hold: true, + close: async () => { + await release.promise + closed = true + }, + }]) + const iterator = stream[Symbol.asyncIterator]() + await iterator.next() + const pending = iterator.next() + + const disposing = stream.dispose() + expect(stream.dispose()).toBe(disposing) + await Promise.resolve() + expect(closed).toBe(false) + release.resolve(undefined) + + await expect(disposing).resolves.toBeUndefined() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + expect(closed).toBe(true) + }) + + it('uses the domain normal-end classification and permits one consumer', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, [{}]) + const iterator = stream[Symbol.asyncIterator]() + + expect(() => stream[Symbol.asyncIterator]()).toThrow('already has a consumer') + await expect(iterator.next()).rejects.toThrow('generation ended before acceptance') + await stream.dispose() + }) + + it('can be disposed before consumption and ignores later restart', async () => { + const source = hostSource(true) + const stream = supervisor(source.connection, []) + + await stream.dispose() + expect(stream.signal.aborted).toBe(true) + stream.restart() + await expect(stream[Symbol.asyncIterator]().next()).resolves.toEqual({ + done: true, + value: undefined, + }) + }) + + it('drops a value that arrives after disposal begins', async () => { + const source = hostSource(true) + const late = Promise.withResolvers() + const stream = supervisor(source.connection, [{ values: [late.promise] }]) + const pending = stream[Symbol.asyncIterator]().next() + const disposing = stream.dispose() + late.resolve('late') + + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + await disposing + }) +}) diff --git a/packages/api/gateway/tests/gateway-stream.host.spec.ts b/packages/api/gateway/tests/gateway-stream.host.spec.ts new file mode 100644 index 0000000000..d7289783d3 --- /dev/null +++ b/packages/api/gateway/tests/gateway-stream.host.spec.ts @@ -0,0 +1,1130 @@ +import { randomUUID } from 'node:crypto' +import { once } from 'node:events' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket, { type RawData } from 'ws' +import { Context, Service, symbols } from '@deepseek-ai/cordis' +import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { + bindTypertRemote, + Remote, + type InvocationDescriptor, + type TypertContextMap, + type TypertContextWire, + TypertRemoteFailure, +} from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { provideBrowserCredentials } from './browser-credentials.ts' +import TypertGatewayService, { + TypertGatewayError, + type TypertRemoteEventDispatch, + type TypertRemoteEventInvocation, + type TypertRemoteEventOutcome, +} from '@deepseek-ai/dsh-api-gateway' +import { z } from 'zod' +import type { + RemoteEventClientId, + RemoteEventInvocationFrame, +} from '../src/stream-protocol.ts' + +vi.mock('node:crypto', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, randomUUID: vi.fn(actual.randomUUID) } +}) + +const randomUuid = vi.mocked(randomUUID) +const browserCookies = new WeakMap() +type AgentWireId = TypertContextWire +const agentId = (value: string): AgentWireId => value as AgentWireId + +/** Exchange this test Host's process token for its WebSocket/HTTP Cookie header. */ +function browserCookie(ctx: Context): string { + const existing = browserCookies.get(ctx) + if (existing !== undefined) return existing + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const target = new URL(ctx.connection.authenticatedUrl(origin)) + let setCookie: string | undefined + ctx.connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway stream fixture did not receive a browser cookie') + const cookie = setCookie.split(';', 1)[0]! + browserCookies.set(ctx, cookie) + return cookie +} + +class FeedService extends Service { + readonly typertRemote = bindTypertRemote(this, 'feed') + readonly signals: AbortSignal[] = [] + returns = 0 + + constructor(ctx: Context) { + super(ctx, 'feed') + } + + @Remote({ mode: 'stream' }) + async *follow(label: string, signal: AbortSignal): AsyncIterable { + this.signals.push(signal) + try { + yield `${label}:ready` + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + this.returns += 1 + } + } + + @Remote({ mode: 'stream' }) + *sync(label: string): Iterable { + yield `${label}:one` + yield `${label}:two` + } + + @Remote({ mode: 'stream' }) + *invalid(): Iterable { + yield 42 as unknown as string + } + + @Remote({ mode: 'stream' }) + *nonJson(): Iterable { + yield 1n + } + + @Remote({ mode: 'stream' }) + missing(): Iterable { + return null as unknown as Iterable + } + + @Remote({ mode: 'stream' }) + *src(label: string): Iterable { + yield `${label}:src` + } + + @Remote({ mode: 'stream' }) + abortBeforeOpen(signal: AbortSignal): Iterable { + if (signal.aborted) throw new Error('fixture observed pre-open cancellation') + return [] + } + + @Remote({ mode: 'stream' }) + reject(): Iterable { + throw new TypertRemoteFailure({ + code: 'fixture-rejected', message: 'fixture rejected the stream', details: { retryable: false }, + }) + } + + @Remote({ mode: 'stream' }) + rejectWithNonJsonDetails(): Iterable { + throw new TypertRemoteFailure({ + code: 'fixture-broken', message: 'fixture emitted invalid details', details: { count: 1n }, + }) + } + + unary(label: string): string { + return label + } +} + +const roots: Context[] = [] + +class RemoteEventSourceProbe { + readonly source = (signal: AbortSignal): AsyncIterable => { + this.signal = signal + return this.iterate(signal) + } + + signal: AbortSignal | undefined + private readonly dispatches: TypertRemoteEventDispatch[] = [] + private wake: (() => void) | undefined + + push(dispatch: TypertRemoteEventDispatch): void { + this.dispatches.push(dispatch) + this.wake?.() + this.wake = undefined + } + + private async *iterate(signal: AbortSignal): AsyncGenerator { + const aborted = (): void => { + this.wake?.() + this.wake = undefined + } + signal.addEventListener('abort', aborted, { once: true }) + try { + while (!signal.aborted) { + while (this.dispatches.length > 0) { + yield this.dispatches.shift() as TypertRemoteEventDispatch + } + if (signal.aborted) return + await new Promise((resolve) => { this.wake = resolve }) + this.wake = undefined + } + } finally { + signal.removeEventListener('abort', aborted) + } + } +} + +interface PendingInvocationProbe { + readonly dispatch: TypertRemoteEventInvocation + readonly outcome: Promise + readonly resolve: (outcome: TypertRemoteEventOutcome) => void + readonly reject: (reason: unknown) => void +} + +function pendingInvocation( + context: Context, + signal?: AbortSignal, + prompt = 'ship', +): PendingInvocationProbe { + const subject = { ctx: context } + const settled = Promise.withResolvers() + const resolve = vi.fn((outcome: TypertRemoteEventOutcome) => { + settled.resolve(outcome) + }) + const reject = vi.fn((reason: unknown) => { + settled.reject(reason) + }) + return { + dispatch: { + event: 'fixture/approval', + request: { prompt, agent: subject, ...(signal === undefined ? {} : { signal }) }, + context: { value: context, subject }, + resolve, + reject, + }, + outcome: settled.promise, + resolve, + reject, + } +} + +afterEach(async () => { + randomUuid.mockClear() + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +describe('Typert Remote streams', () => { + it('opens decoded carrier payloads through the in-process wire adapter', async () => { + const { ctx } = await setup(false) + const source = await ctx.typertGateway.wireStream.open( + 'feed/sync', + { args: { label: 'wire' } }, + new AbortController().signal, + ) + + await expect(collect(source)).resolves.toEqual(['wire:one', 'wire:two']) + }) + + it('passes Iterable and AsyncIterable items through and returns the iterator on cancellation', async () => { + const { ctx, service } = await setup(false) + const abort = new AbortController() + const source = await ctx.typertGateway.stream({ + namespace: 'feed', + method: 'follow', + args: { label: 'a' }, + signal: abort.signal, + }) + const iterator = source[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toEqual({ done: false, value: 'a:ready' }) + const pending = iterator.next() + abort.abort(new Error('fixture cancellation')) + await expect(pending).rejects.toThrow('Remote invocation "feed/follow" was aborted') + expect(service.signals).toEqual([abort.signal]) + expect(service.returns).toBe(1) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'b' }, + }))).resolves.toEqual(['b:one', 'b:two']) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'invalid', args: {}, + }))).resolves.toEqual([42]) + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'nonJson', args: {}, + }))).resolves.toEqual([1n]) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'missing', args: {}, + })).rejects.toMatchObject({ code: 'result-invalid' }) + + await expect(collect(await ctx.typertGateway.stream({ + namespace: 'feed', method: 'src', args: { label: 'c' }, + }))).resolves.toEqual(['c:src']) + + const abortedBeforeOpen = new AbortController() + abortedBeforeOpen.abort(new Error('cancelled before open')) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'abortBeforeOpen', args: {}, signal: abortedBeforeOpen.signal, + })).rejects.toThrow('Remote invocation "feed/abortBeforeOpen" was aborted') + + const abortedBeforeIteration = new AbortController() + abortedBeforeIteration.abort(new Error('cancelled before iteration')) + const preCancelled = await ctx.typertGateway.stream({ + namespace: 'feed', method: 'sync', args: { label: 'ignored' }, signal: abortedBeforeIteration.signal, + }) + await expect(collect(preCancelled)).rejects.toThrow('Remote invocation "feed/sync" was aborted') + }) + + it('keeps unary and stream invocation modes distinct', async () => { + const { ctx } = await setup(false) + await expect(ctx.typertGateway.invoke({ + namespace: 'feed', method: 'sync', args: { label: 'a' }, + })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + await expect(ctx.typertGateway.stream({ + namespace: 'feed', method: 'unary', args: { label: 'a' }, + })).rejects.toMatchObject({ code: 'signature-invalid' } satisfies Partial) + }) + + it('multiplexes independent streams over one WebSocket and propagates cancellation', async () => { + const { ctx, service } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'a', 'feed/follow', { label: 'a' }) + sendOpen(socket, 'b', 'feed/follow', { label: 'b' }) + await vi.waitFor(() => { + expect(frames).toEqual(expect.arrayContaining([ + { type: 'item', streamId: 'a', value: 'a:ready' }, + { type: 'item', streamId: 'b', value: 'b:ready' }, + ])) + }) + expect(service.signals.map(signal => signal.aborted)).toEqual([false, false]) + expect(service.returns).toBe(0) + + socket.send(JSON.stringify({ type: 'cancel', streamId: 'a' })) + await vi.waitFor(() => { expect(service.returns).toBe(1) }) + expect(service.signals[0]?.aborted).toBe(true) + expect(service.signals[1]?.aborted).toBe(false) + + sendOpen(socket, 'sync', 'feed/sync', { label: 's' }) + sendOpen(socket, 'invalid', 'feed/invalid', {}) + sendOpen(socket, 'non-json', 'feed/nonJson', {}) + sendOpen(socket, 'rejected', 'feed/reject', {}) + await vi.waitFor(() => { + expect(frames.filter(frame => frame.streamId === 'sync')).toEqual([ + { type: 'item', streamId: 'sync', value: 's:one' }, + { type: 'item', streamId: 'sync', value: 's:two' }, + { type: 'end', streamId: 'sync' }, + ]) + expect(frames.filter(frame => frame.streamId === 'invalid')).toEqual([ + { type: 'item', streamId: 'invalid', value: 42 }, + { type: 'end', streamId: 'invalid' }, + ]) + expect(frames.find(frame => frame.streamId === 'non-json')).toMatchObject({ + type: 'error', error: { code: 'internal' }, + }) + expect(frames.find(frame => frame.streamId === 'rejected')).toEqual({ + type: 'error', + streamId: 'rejected', + error: { + code: 'fixture-rejected', + message: 'fixture rejected the stream', + details: { retryable: false }, + }, + }) + }) + + const closed = once(socket, 'close') + sendOpen(socket, 'broken-error', 'feed/rejectWithNonJsonDetails', {}) + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + await vi.waitFor(() => { expect(service.returns).toBe(2) }) + expect(service.signals[1]?.aborted).toBe(true) + }) + + it('carries the registered Remote event source and withdraws its active stream', async () => { + const { ctx } = await setup(true) + let sourceSignal: AbortSignal | undefined + const sourceClosed = vi.fn() + const publish = Promise.withResolvers() + const source = (signal: AbortSignal): AsyncIterable<{ event: string; args: readonly unknown[] }> => { + sourceSignal = signal + return (async function *() { + try { + await publish.promise + yield { event: 'fixture/changed', args: ['settings'] } + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + sourceClosed() + } + })() + } + const unregister = ctx.typertGateway.registerRemoteEvents(source) + expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + .toThrow('forwarded Remote event source is already registered') + + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, 'events', '$events', {}) + + await vi.waitFor(() => { + const eventFrames = frames.filter(frame => frame.streamId === 'events') + expect(eventFrames).toHaveLength(1) + expect(eventFrames[0]).toMatchObject({ + type: 'item', streamId: 'events', value: { type: 'ready' }, + }) + expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') + }) + publish.resolve(undefined) + await vi.waitFor(() => { + const eventFrames = frames.filter(frame => frame.streamId === 'events').slice(0, 2) + expect(eventFrames).toHaveLength(2) + expect(eventFrames[0]).toMatchObject({ + type: 'item', streamId: 'events', value: { type: 'ready' }, + }) + expect(typeof Reflect.get(eventFrames[0]!.value as object, 'clientId')).toBe('string') + expect(eventFrames[1]).toEqual({ + type: 'item', streamId: 'events', value: { + type: 'emit', event: 'fixture/changed', args: ['settings'], + }, + }) + }) + expect(sourceSignal?.aborted).toBe(false) + + await unregister() + expect(sourceClosed).toHaveBeenCalledOnce() + await vi.waitFor(() => { + expect(sourceSignal?.aborted).toBe(true) + expect(frames).toContainEqual({ type: 'end', streamId: 'events' }) + }) + + const unregisterReplacement = ctx.typertGateway.registerRemoteEvents(source) + await unregister() + expect(() => { ctx.typertGateway.registerRemoteEvents(source) }) + .toThrow('forwarded Remote event source is already registered') + await unregisterReplacement() + socket.close() + }) + + it('rejects a scoped dispatch yielded after its Remote event source is withdrawn', async () => { + const { ctx } = await setup(false) + const publish = Promise.withResolvers() + const agent = ctx.extend() + const pending = pendingInvocation(agent) + const source = (): AsyncIterable => (async function* () { + await publish.promise + yield pending.dispatch + })() + const unregister = ctx.typertGateway.registerRemoteEvents(source) + const rejected = expect(pending.outcome).rejects.toThrow( + 'forwarded Remote event source was removed', + ) + + publish.resolve(undefined) + await unregister() + + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + }) + + it('cancels a pending waterfall when its source rejects during removal', async () => { + const { ctx } = await setup(true) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-removal') : undefined, + resolve: id => id === 'agent-removal' ? agent : undefined, + }) + const pending = pendingInvocation(agent) + const rejected = expect(pending.outcome).rejects.toThrow( + 'forwarded Remote event source was removed', + ) + const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () { + yield pending.dispatch + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + throw new Error('fixture source rejected during removal') + })()) + const client = await openEventClient(ctx, 'events-removal') + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + + await unregister() + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ type: 'end', streamId: client.streamId }) + }) + client.socket.close() + }) + + it('delegates unavailable Contexts and rejects malformed scoped invocations', async () => { + const { ctx } = await setup(false) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + + for (const event of [42, ''] as const) { + const invalidName = pendingInvocation(ctx) + const rejected = expect(invalidName.outcome).rejects.toThrow( + 'Remote event name must be a nonempty string', + ) + source.push({ + ...invalidName.dispatch, + event: event as unknown as string, + }) + await rejected + } + + const unavailable = pendingInvocation(ctx) + source.push(unavailable.dispatch) + await expect(unavailable.outcome).resolves.toEqual({ kind: 'next' }) + expect(unavailable.reject).not.toHaveBeenCalled() + + let selected = ctx.extend() + let identity: unknown = 1n + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === selected ? identity as AgentWireId : undefined, + resolve: () => selected, + }) + const nonJsonIdentity = pendingInvocation(selected) + const nonJsonRejected = expect(nonJsonIdentity.outcome).rejects.toThrow( + 'require a non-empty Agent identity', + ) + source.push(nonJsonIdentity.dispatch) + await nonJsonRejected + + identity = 'agent-invalid-request' + const invalidRequest = pendingInvocation(selected) + const invalidRequestRejected = expect(invalidRequest.outcome).rejects.toThrow( + 'must carry its scoped Agent directly', + ) + source.push({ + ...invalidRequest.dispatch, + request: {}, + }) + await invalidRequestRejected + + const staleFiber = ctx.plugin(() => {}) + await staleFiber + selected = staleFiber.ctx + identity = 'agent-stale' + await staleFiber.dispose() + const stale = pendingInvocation(selected) + source.push(stale.dispatch) + await expect(stale.outcome).resolves.toEqual({ kind: 'next' }) + expect(stale.reject).not.toHaveBeenCalled() + + selected = ctx.extend() + identity = 'agent-cancelled' + const abort = new AbortController() + abort.abort('fixture non-error cancellation') + const cancelled = pendingInvocation(selected, abort.signal) + const cancelledOutcome = expect(cancelled.outcome).rejects.toMatchObject({ + message: 'typert gateway: Remote event was cancelled', + cause: 'fixture non-error cancellation', + }) + source.push(cancelled.dispatch) + await cancelledOutcome + + await unregister() + }) + + it('rejects notification arguments that are not lossless JSON arrays', async () => { + const { ctx } = await setup(false) + const frames = [ + { event: 'fixture/changed', args: {} }, + { event: 'fixture/changed', args: [1n] }, + ] + for (const frame of frames) { + let sourceSignal: AbortSignal | undefined + const unregister = ctx.typertGateway.registerRemoteEvents((signal) => { + sourceSignal = signal + return (async function* () { + yield frame as unknown as TypertRemoteEventDispatch + })() + }) + await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) + const reason: unknown = sourceSignal?.reason + if (!(reason instanceof Error)) throw new Error('Remote event source did not fail with an Error') + expect(reason.message).toContain('arguments are not lossless JSON data') + await unregister() + } + }) + + it('retries a colliding Remote event id before publishing the second waterfall', async () => { + const { ctx } = await setup(false) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-collision') : undefined, + resolve: id => id === 'agent-collision' ? agent : undefined, + }) + const firstId = '00000000-0000-4000-8000-000000000001' as ReturnType + const secondId = '00000000-0000-4000-8000-000000000002' as ReturnType + randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = pendingInvocation(agent, firstAbort.signal, 'first') + const second = pendingInvocation(agent, secondAbort.signal, 'second') + + source.push(first.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) + source.push(second.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(3) }) + + const firstReason = new Error('cancel first collision fixture') + const secondReason = new Error('cancel second collision fixture') + const firstRejected = expect(first.outcome).rejects.toBe(firstReason) + const secondRejected = expect(second.outcome).rejects.toBe(secondReason) + firstAbort.abort(firstReason) + secondAbort.abort(secondReason) + await firstRejected + await secondRejected + await unregister() + }) + + it('retries a colliding Remote event Client id before opening the second generation', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const firstId = '00000000-0000-4000-8000-000000000011' as ReturnType + const secondId = '00000000-0000-4000-8000-000000000012' as ReturnType + randomUuid.mockReturnValueOnce(firstId).mockReturnValueOnce(firstId).mockReturnValueOnce(secondId) + + const first = await openEventClient(ctx, 'events-client-id-a') + const second = await openEventClient(ctx, 'events-client-id-b') + + expect(first.clientId).toBe(firstId) + expect(second.clientId).toBe(secondId) + expect(randomUuid).toHaveBeenCalledTimes(3) + first.socket.close() + second.socket.close() + await unregister() + }) + + it('fans one scoped waterfall out and accepts the first Client result', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const first = await openEventClient(ctx, 'events-a') + const second = await openEventClient(ctx, 'events-b') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + + await vi.waitFor(() => { + expect(deliveredInvocation(first)).toBeDefined() + expect(deliveredInvocation(second)).toBeDefined() + }) + const firstFrame = deliveredInvocation(first)! + const secondFrame = deliveredInvocation(second)! + expect(firstFrame.eventId).toBe(secondFrame.eventId) + expect(firstFrame).toMatchObject({ + type: 'waterfall', + event: 'fixture/approval', + agentId: 'agent-1', + request: { prompt: 'ship' }, + }) + expect(firstFrame).not.toHaveProperty('deliveryId') + expect(secondFrame).not.toHaveProperty('deliveryId') + + await sendEventResult(second, secondFrame, { + kind: 'result', value: 'allowed', + }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + await vi.waitFor(() => { + expect(first.frames).toContainEqual({ + type: 'item', + streamId: first.streamId, + value: { type: 'cancel', eventId: firstFrame.eventId }, + }) + }) + + await sendEventResult(first, firstFrame, { + kind: 'result', value: 'rejected', + }) + expect(pending.resolve).toHaveBeenCalledTimes(1) + expect(pending.reject).not.toHaveBeenCalled() + first.socket.close() + second.socket.close() + await unregister() + }) + + it('rejects the Host waterfall with the first Client listener rejection', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-rejected') : undefined, + resolve: id => id === 'agent-rejected' ? agent : undefined, + }) + const client = await openEventClient(ctx, 'events-rejected') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const frame = deliveredInvocation(client)! + const rejected = expect(pending.outcome).rejects.toMatchObject({ + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }) + + await sendEventResult(client, frame, { + kind: 'rejected', + error: { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }, + }) + await rejected + expect(pending.reject).toHaveBeenCalledTimes(1) + expect(pending.resolve).not.toHaveBeenCalled() + + client.socket.close() + await unregister() + }) + + it('delegates to the Host only after every active Client returns next', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const first = await openEventClient(ctx, 'events-next-a') + const second = await openEventClient(ctx, 'events-next-b') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { + expect(deliveredInvocation(first)).toBeDefined() + expect(deliveredInvocation(second)).toBeDefined() + }) + const firstFrame = deliveredInvocation(first)! + const secondFrame = deliveredInvocation(second)! + + await sendEventResult(first, firstFrame, { kind: 'next' }) + expect(pending.resolve).not.toHaveBeenCalled() + await sendEventResult(second, secondFrame, { kind: 'next' }) + await expect(pending.outcome).resolves.toEqual({ kind: 'next' }) + expect(pending.resolve).toHaveBeenCalledTimes(1) + expect(pending.reject).not.toHaveBeenCalled() + first.socket.close() + second.socket.close() + await unregister() + }) + + it('delivers a pending waterfall to the first Client that connects', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-late-client') : undefined, + resolve: id => id === 'agent-late-client' ? agent : undefined, + }) + const pending = pendingInvocation(agent, undefined, 'before-connect') + + source.push(pending.dispatch) + await vi.waitFor(() => { expect(randomUuid).toHaveBeenCalledTimes(1) }) + + const client = await openEventClient(ctx, 'events-first-client') + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const frame = deliveredInvocation(client)! + expect(frame).toMatchObject({ + type: 'waterfall', + event: 'fixture/approval', + agentId: 'agent-late-client', + request: { prompt: 'before-connect' }, + }) + + await sendEventResult(client, frame, { kind: 'result', value: 'allowed' }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + + client.socket.close() + await unregister() + }) + + it('replays a pending event id to a replacement Client generation', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const agent = ctx.extend() + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: candidate => candidate === agent ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? agent : undefined, + }) + const original = await openEventClient(ctx, 'events-original') + const pending = pendingInvocation(agent) + source.push(pending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(original)).toBeDefined() }) + const originalFrame = deliveredInvocation(original)! + const closed = once(original.socket, 'close') + original.socket.close() + await closed + + const replacement = await openEventClient(ctx, 'events-replacement') + await vi.waitFor(() => { expect(deliveredInvocation(replacement)).toBeDefined() }) + const replayed = deliveredInvocation(replacement)! + expect(replayed.eventId).toBe(originalFrame.eventId) + expect(replayed).not.toHaveProperty('deliveryId') + await sendEventResult(replacement, replayed, { + kind: 'result', value: 'allowed', + }) + await expect(pending.outcome).resolves.toEqual({ kind: 'result', value: 'allowed' }) + + replacement.socket.close() + await unregister() + }) + + it('cancels pending deliveries when the Host signal or Context ends', async () => { + const { ctx } = await setup(true) + const source = new RemoteEventSourceProbe() + const unregister = ctx.typertGateway.registerRemoteEvents(source.source) + const signalAgent = ctx.extend() + const contextFiber = ctx.plugin(() => {}) + await contextFiber + const contextAgent = contextFiber.ctx + ctx.typert.contexts.registerHost('agent', { + wire: 'agentId', + wireTypeSymbol: '@fixture#AgentId', + identity: (candidate) => { + if (candidate === signalAgent) return agentId('agent-signal') + if (candidate === contextAgent) return agentId('agent-context') + return undefined + }, + resolve: (id) => { + if (id === 'agent-signal') return signalAgent + if (id === 'agent-context') return contextAgent + return undefined + }, + }) + const client = await openEventClient(ctx, 'events-cancel') + + const abort = new AbortController() + const signalPending = pendingInvocation(signalAgent, abort.signal, 'signal') + source.push(signalPending.dispatch) + await vi.waitFor(() => { expect(deliveredInvocation(client)).toBeDefined() }) + const signalFrame = deliveredInvocation(client)! + expect(signalFrame).toMatchObject({ + type: 'waterfall', + agentId: 'agent-signal', + request: { prompt: 'signal' }, + }) + const signalReason = new Error('Host caller cancelled') + const signalOutcome = expect(signalPending.outcome).rejects.toBe(signalReason) + abort.abort(signalReason) + await signalOutcome + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ + type: 'item', + streamId: client.streamId, + value: { type: 'cancel', eventId: signalFrame.eventId }, + }) + }) + + const contextPending = pendingInvocation(contextAgent, undefined, 'context') + source.push(contextPending.dispatch) + let contextFrame: RemoteEventInvocationFrame | undefined + await vi.waitFor(() => { + contextFrame = client.frames + .filter(frame => frame.type === 'item' && frame.streamId === client.streamId) + .map(frame => frame.value) + .find(value => typeof value === 'object' + && value !== null + && Reflect.get(value, 'event') === 'fixture/approval' + && Reflect.get(value, 'eventId') !== signalFrame.eventId) as RemoteEventInvocationFrame | undefined + expect(contextFrame).toBeDefined() + }) + const contextOutcome = expect(contextPending.outcome).rejects.toThrow('Context "agent" was released') + await contextFiber.dispose() + await contextOutcome + await vi.waitFor(() => { + expect(client.frames).toContainEqual({ + type: 'item', + streamId: client.streamId, + value: { type: 'cancel', eventId: contextFrame!.eventId }, + }) + }) + + client.socket.close() + await unregister() + }) + + it('validates the internal Remote event request and reports an absent source', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, { + headers: { cookie: browserCookie(ctx) }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + + sendOpen(socket, 'missing', '$events', {}) + await vi.waitFor(() => { + expect(frames.find(frame => frame.streamId === 'missing')?.type).toBe('error') + expect(streamErrorMessage(frames, 'missing')).toContain('source is unavailable') + }) + + let sourceCalls = 0 + const unregister = ctx.typertGateway.registerRemoteEvents(() => { + sourceCalls += 1 + return (async function *(): AsyncIterable {})() + }) + const invalidPayloads: readonly unknown[] = [ + null, + [], + {}, + { other: {} }, + { args: null }, + { args: [] }, + { args: { extra: true } }, + ] + invalidPayloads.forEach((payload, index) => { + socket.send(JSON.stringify({ + type: 'open', streamId: `invalid-${String(index)}`, endpoint: '$events', payload, + })) + }) + await vi.waitFor(() => { + expect(frames.filter(frame => String(frame.streamId).startsWith('invalid-'))).toHaveLength(invalidPayloads.length) + }) + for (const [index] of invalidPayloads.entries()) { + const streamId = `invalid-${String(index)}` + expect(frames.find(frame => frame.streamId === streamId)?.type).toBe('error') + expect(streamErrorMessage(frames, streamId)).toContain('requires an empty args object') + } + expect(sourceCalls).toBe(1) + + await unregister() + socket.close() + }) + + it('applies Connection trusted-host policy before accepting the Gateway socket', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket( + `ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`, + { headers: { host: 'untrusted.example' } }, + ) + socket.on('error', () => {}) + const responseEvent: unknown[] = await once(socket, 'unexpected-response') + const request = responseEvent[0] + const response = responseEvent[1] + const rejected = response as { statusCode?: number; resume(): void } + expect(rejected.statusCode).toBe(403) + rejected.resume() + ;(request as { abort(): void }).abort() + }) + + it('answers an unauthenticated trusted Host with 401 before opening a stream', async () => { + const { ctx } = await setup(true) + const socket = new WebSocket(`ws://127.0.0.1:${String(ctx.webServer.port)}/api/remote.mux`) + socket.on('error', () => {}) + const responseEvent: unknown[] = await once(socket, 'unexpected-response') + const request = responseEvent[0] + const response = responseEvent[1] + const rejected = response as { statusCode?: number; resume(): void } + expect(rejected.statusCode).toBe(401) + rejected.resume() + ;(request as { abort(): void }).abort() + }) +}) + +async function setup(transport: boolean): Promise<{ readonly ctx: Context; readonly service: FeedService }> { + const ctx = new Context() + roots.push(ctx) + if (transport) { + await ctx.plugin(WebServer, { host: '127.0.0.1', port: 0 }) + provideBrowserCredentials(ctx) + } + await ctx.plugin(TypertRegistry) + await ctx.plugin(TypertGatewayService) + if (transport) { + await ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) + } + await ctx.plugin(FeedService) + ctx.typert.register({ + package: '@fixture/feed', + face: 'host', + schemas: [], + model: { services: [], events: [], objects: [] }, + invocations: descriptors(), + }) + const receiver = ctx.get('feed') as unknown as FeedService & { [symbols.original]?: FeedService } + return { ctx, service: receiver[symbols.original] ?? receiver } +} + +function descriptors(): InvocationDescriptor[] { + const label = { + name: 'label', + wire: 'label', + source: 'json' as const, + codec: { mode: 'strict' as const, typeSymbol: '@fixture/feed#Label', schema: z.string() }, + } + const stream = (method: string, parameters: InvocationDescriptor['parameters'], schema: z.ZodType): InvocationDescriptor => ({ + id: `@fixture/feed#feed/${method}`, + service: 'feed', + namespace: 'feed', + method, + mode: 'stream', + invocation: { kind: 'direct' }, + parameters, + result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema }, + }) + return [ + { ...stream('follow', [label], z.string()), cancellation: { parameter: 'signal' } }, + stream('sync', [label], z.string()), + stream('invalid', [], z.string()), + stream('nonJson', [], z.unknown()), + stream('missing', [], z.string()), + { ...stream('abortBeforeOpen', [], z.string()), cancellation: { parameter: 'signal' } }, + stream('reject', [], z.string()), + stream('rejectWithNonJsonDetails', [], z.string()), + { + id: '@fixture/feed#feed/unary', + service: 'feed', + namespace: 'feed', + method: 'unary', + invocation: { kind: 'direct' }, + parameters: [label], + result: { mode: 'strict', typeSymbol: '@fixture/feed#Item', schema: z.string() }, + }, + ] +} + +interface RemoteEventTestClient { + readonly socket: WebSocket + readonly frames: Record[] + readonly streamId: string + readonly clientId: RemoteEventClientId + readonly origin: string + readonly cookie: string +} + +async function openEventClient(ctx: Context, streamId: string): Promise { + const origin = `http://127.0.0.1:${String(ctx.webServer.port)}` + const cookie = browserCookie(ctx) + const socket = new WebSocket(`${origin.replace('http:', 'ws:')}/api/remote.mux`, { + headers: { cookie }, + }) + await once(socket, 'open') + const frames: Record[] = [] + socket.on('message', (data) => { frames.push(JSON.parse(rawText(data)) as Record) }) + sendOpen(socket, streamId, '$events', {}) + let clientId: RemoteEventClientId | undefined + await vi.waitFor(() => { + const ready = frames.find(frame => frame.type === 'item' + && frame.streamId === streamId + && typeof frame.value === 'object' + && frame.value !== null + && Reflect.get(frame.value, 'type') === 'ready') + const candidate: unknown = ready === undefined ? undefined : Reflect.get(ready.value as object, 'clientId') + expect(typeof candidate).toBe('string') + if (typeof candidate === 'string') clientId = candidate as RemoteEventClientId + }) + if (clientId === undefined) throw new Error('Remote event stream omitted its Client id') + return { socket, frames, streamId, clientId, origin, cookie } +} + +function deliveredInvocation(client: RemoteEventTestClient): RemoteEventInvocationFrame | undefined { + for (const frame of client.frames) { + if (frame.type !== 'item' || frame.streamId !== client.streamId) continue + const value = frame.value + if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'eventId')) continue + return value as RemoteEventInvocationFrame + } + return undefined +} + +async function sendEventResult( + client: RemoteEventTestClient, + frame: RemoteEventInvocationFrame, + outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + }, +): Promise { + const rpcId = `remote-event-result-${client.streamId}` + const response = await fetch(`${client.origin}/api/$events/result`, { + method: 'POST', + headers: { 'content-type': 'application/json', cookie: client.cookie }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: '$events/result', + payload: { + args: { clientId: client.clientId, eventId: frame.eventId, outcome }, + }, + }), + }) + expect(response.status).toBe(200) + const body = await response.json() as { readonly result?: { readonly ok?: boolean; readonly error?: { message?: string } } } + if (body.result?.ok !== true) { + throw new Error(body.result?.error?.message ?? 'Remote event result failed') + } +} + +function sendOpen(socket: WebSocket, streamId: string, endpoint: string, args: object): void { + socket.send(JSON.stringify({ type: 'open', streamId, endpoint, payload: { args } })) +} + +function rawText(data: RawData): string { + if (Array.isArray(data)) return Buffer.concat(data).toString('utf8') + if (data instanceof ArrayBuffer) return Buffer.from(data).toString('utf8') + return Buffer.from(data).toString('utf8') +} + +function streamErrorMessage(frames: readonly Record[], streamId: string): string | undefined { + const error = frames.find(frame => frame.streamId === streamId)?.error + if (typeof error !== 'object' || error === null) return undefined + const message = Reflect.get(error, 'message') as unknown + return typeof message === 'string' ? message : undefined +} + +async function collect(source: AsyncIterable): Promise { + const values: unknown[] = [] + for await (const value of source) values.push(value) + return values +} diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index 59add544ea..92eeecb5e0 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -2,18 +2,38 @@ import { Context, Service } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import { describe, expect, expectTypeOf, it, vi } from 'vitest' import { z } from 'zod' -import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { + apply as applyConnection, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, RemoteResult, - TypertClientRemote, + TypertContextMap, + TypertContextWire, TypertContext, + TypertLookup, TypertRemoteScopeApi, TypertRemoteNamespace, } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { ClientRemote } from '../src/client/index.ts' -import { apply, inject } from '../src/client/index.ts' +import { apply, inject, RemoteStream } from '../src/client/index.ts' +import { + RemoteStreamCarrierError, + RemoteStreamError, + RemoteStreamMuxClient, +} from '../src/client/stream-client.ts' + +type FixtureApprovalOutcome = 'allowed' | 'unavailable' +const fixtureContextTag = Symbol('fixture-context-tag') +type AgentWireId = TypertContextWire +const agentId = (value: string): AgentWireId => value as AgentWireId + +interface FixtureAgent { + readonly agentId: string +} declare module '@deepseek-ai/cordis' { interface Events { @@ -27,6 +47,21 @@ declare module '@deepseek-ai/cordis' { * @param count - marker payload never observed. */ 'fixture/idle'(count: number): void + /** + * Test-only scoped waterfall forwarded through the existing Remote Event stream. + * @param request - JSON-safe request payload. + * @param next - delegates to the next Client listener or Host waterfall. + * @returns the claimed or delegated outcome. + */ + 'fixture/approval'( + this: Context, + request: { + readonly prompt: string + readonly agent: FixtureAgent + readonly signal?: AbortSignal + }, + next: () => Promise, + ): Promise /** * Test-only event the Host assembly does not forward. * @param flag - marker payload never delivered. @@ -36,12 +71,17 @@ declare module '@deepseek-ai/cordis' { } declare module '@deepseek-ai/dsh-typert-protocol' { - interface TypertRemoteEventSelection extends Record<'fixture/changed' | 'fixture/idle', true> {} + interface TypertRemoteEventSelection extends + Record<'fixture/changed' | 'fixture/idle' | 'fixture/approval', true> {} interface TypertContextMap { fixture: TypertContext } + interface TypertLookupMap { + fixture: TypertLookup + } + interface TypertRemoteMap { 'probe/create': ( agentId: string, @@ -49,6 +89,7 @@ declare module '@deepseek-ai/dsh-typert-protocol' { signal?: AbortSignal, ) => Promise> 'probe/maybe': (value: string | null | undefined) => Promise> + 'probe/watch': (topic: string, signal?: AbortSignal) => AsyncIterable } interface TypertRemoteScopeMap { @@ -68,13 +109,19 @@ declare module '@deepseek-ai/dsh-typert-protocol' { } type FixtureContext = Omit & { - readonly remote: TypertClientRemote & TypertRemoteScopeApi<'fixture'> + readonly remote: ClientRemote & TypertRemoteScopeApi<'fixture'> } // Compile-time contract of `$on`: the key face is the forwarding selection and // the listener signature is the owning package's own Cordis declaration. function remoteEventContracts(remote: ClientRemote): void { remote.$on('fixture/changed', (namespace) => { void namespace }) + remote.$on('fixture/approval', async function (request, next) { + expectTypeOf(this).toEqualTypeOf() + expectTypeOf(request.agent).toEqualTypeOf() + expectTypeOf(request.signal).toEqualTypeOf() + return request.prompt === '' ? next() : 'allowed' + }) // @ts-expect-error -- declared in Events but outside the forwarding selection. remote.$on('fixture/unselected', () => {}) // @ts-expect-error -- not declared in Events at all. @@ -155,24 +202,392 @@ function maybeDescriptor(): InvocationDescriptor { } } -async function bench(call: ConnectionHandle['rpc']['call']): Promise { - const { ctx } = await benchFiber(call) +function streamDescriptor(): InvocationDescriptor { + return { + id: '@fixture/probe#probe/watch', + service: 'probe', + namespace: 'probe', + method: 'watch', + mode: 'stream', + invocation: { kind: 'direct' }, + parameters: [{ + name: 'topic', + wire: 'topic', + source: 'json', + codec: { mode: 'strict', typeSymbol: '@fixture#Topic', schema: z.string().min(1) }, + }], + cancellation: { parameter: 'signal' }, + result: { mode: 'strict', typeSymbol: '@fixture#WatchItem', schema: z.string().min(1) }, + } +} + +type WebSocketGlobal = { WebSocket?: typeof WebSocket } + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0 + static readonly OPEN = 1 + static readonly CLOSING = 2 + static readonly CLOSED = 3 + static readonly sockets: FakeWebSocket[] = [] + static autoOpen = true + static dispatchClose = true + + readonly url: string + readonly sent: string[] = [] + readonly closedWith: { readonly code?: number; readonly reason?: string }[] = [] + readyState = FakeWebSocket.CONNECTING + + constructor(url: string | URL) { + super() + this.url = String(url) + FakeWebSocket.sockets.push(this) + queueMicrotask(() => { + if (FakeWebSocket.autoOpen) this.open() + }) + } + + open(): void { + if (this.readyState !== FakeWebSocket.CONNECTING) return + this.readyState = FakeWebSocket.OPEN + this.dispatchEvent(new Event('open')) + } + + fail(): void { + this.dispatchEvent(new Event('error')) + } + + send(data: string): void { + if (this.readyState !== FakeWebSocket.OPEN) throw new Error('fixture socket is not open') + this.sent.push(data) + } + + close(code?: number, reason?: string): void { + this.closedWith.push({ + ...(code === undefined ? {} : { code }), + ...(reason === undefined ? {} : { reason }), + }) + if (this.readyState === FakeWebSocket.CLOSED) return + if (!FakeWebSocket.dispatchClose) { + this.readyState = FakeWebSocket.CLOSING + return + } + this.drop() + } + + drop(): void { + if (this.readyState === FakeWebSocket.CLOSED) return + this.readyState = FakeWebSocket.CLOSED + this.dispatchEvent(new Event('close')) + } + + receive(value: unknown): void { + this.receiveRaw(typeof value === 'string' ? value : JSON.stringify(value)) + } + + receiveRaw(data: unknown): void { + this.dispatchEvent(new MessageEvent('message', { + data, + })) + } +} + +async function bench( + call: ConnectionHandle['rpc']['call'], + carrier: 'in-process' | 'web' = 'in-process', +): Promise { + const { ctx } = await benchFiber(call, carrier) return ctx } async function benchFiber( call: ConnectionHandle['rpc']['call'], -): Promise<{ readonly ctx: Context; readonly client: Fiber }> { + carrier: 'in-process' | 'web' = 'in-process', + open: NonNullable = () => unexpectedInProcessStream(), +): Promise<{ + readonly ctx: Context + readonly client: Fiber + readonly generation: GenerationHarness +}> { const ctx = new Context() await ctx.plugin(TypertRegistry) - ctx.provide('connection', { rpc: { call } } as unknown as ConnectionHandle) + const rpc = carrier === 'web' + ? { call } + : { call, open } + const generation = new GenerationHarness() + ctx.provide('connection', { + rpc, + registerGenerationSource: generation.register, + start: () => ({ stop: () => {} }), + } as unknown as ConnectionHandle) const client = ctx.plugin({ inject, apply }) await client - return { ctx, client } + return { ctx, client, generation } } +async function *unexpectedInProcessStream(): AsyncGenerator { + throw new Error('fixture did not install an in-process stream') +} + +interface GenerationRun { + readonly signal: AbortSignal + readonly ready: Promise + readonly done: Promise + abort(reason?: unknown): void +} + +class GenerationHarness { + private source: ConnectionGenerationSource | undefined + private active: AbortController | undefined + + readonly register = (source: ConnectionGenerationSource): (() => void) => { + if (this.source !== undefined) throw new Error('fixture generation source already registered') + this.source = source + return () => { + if (this.source !== source) return + this.source = undefined + this.active?.abort(new Error('fixture generation source removed')) + this.active = undefined + } + } + + start(): GenerationRun { + if (this.source === undefined) throw new Error('fixture generation source is not registered') + if (this.active !== undefined) throw new Error('fixture generation is already active') + const source = this.source + const controller = new AbortController() + this.active = controller + let reportReady!: () => void + const ready = new Promise((resolve) => { reportReady = resolve }) + const done = Promise.resolve() + .then(() => source(controller.signal, reportReady)) + .finally(() => { + if (this.active === controller) this.active = undefined + }) + void done.catch(() => undefined) + return { + signal: controller.signal, + ready, + done, + abort: (reason) => { controller.abort(reason) }, + } + } + + startOverlapping(): GenerationRun { + if (this.source === undefined) throw new Error('fixture generation source is not registered') + const controller = new AbortController() + let reportReady!: () => void + const ready = new Promise((resolve) => { reportReady = resolve }) + const done = Promise.resolve().then(() => this.source?.(controller.signal, reportReady)) + .then(() => undefined) + void done.catch(() => undefined) + return { + signal: controller.signal, + ready, + done, + abort: (reason) => { controller.abort(reason) }, + } + } +} + +function deferredReadiness(): { + readonly promise: Promise + readonly resolve: () => void + readonly reject: (error: unknown) => void +} { + let resolve!: () => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, decline) => { + resolve = accept + reject = decline + }) + return { promise, resolve, reject } +} + +async function loaderReadinessBench(readiness: Promise): Promise<{ + readonly client: Fiber + readonly start: ReturnType> + readonly stop: ReturnType void>> +}> { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + const generation = new GenerationHarness() + const stop = vi.fn<() => void>() + const start = vi.fn(() => ({ stop })) + ctx.provide('connection', { + rpc: { + call: vi.fn(), + open: () => unexpectedInProcessStream(), + }, + registerGenerationSource: generation.register, + start, + } as unknown as ConnectionHandle) + ctx.provide('loader', { await: () => readiness }) + const client = ctx.plugin({ inject, apply }) + await client + return { client, start, stop } +} + +type EventStreamItem = + | { readonly kind: 'frame'; readonly value: unknown } + | { readonly kind: 'end' } + | { readonly kind: 'fail'; readonly error: unknown } + +interface EventStreamConnection { + readonly items: EventStreamItem[] + wake: (() => void) | undefined +} + +class RemoteEventCarrier { + readonly calls: { + readonly channel: string + readonly endpoint: string + readonly payload: unknown + readonly signal: AbortSignal + }[] = [] + private readonly connections = new Set() + private nextClient = 1 + + get activeConnections(): number { + return this.connections.size + } + + readonly open: NonNullable = (channel, endpoint, payload, signal) => { + this.calls.push({ channel, endpoint, payload, signal }) + return this.iterate(signal) + } + + emit(value: unknown): void { + this.feed({ kind: 'frame', value }) + } + + end(): void { + this.feed({ kind: 'end' }) + } + + fail(error: unknown): void { + this.feed({ kind: 'fail', error }) + } + + private feed(item: EventStreamItem): void { + for (const connection of this.connections) { + connection.items.push(item) + connection.wake?.() + } + } + + private async *iterate(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const clientId = `event-client-${String(this.nextClient++)}` + const connection: EventStreamConnection = { items: [], wake: undefined } + this.connections.add(connection) + const abort = (): void => { connection.wake?.() } + signal.addEventListener('abort', abort, { once: true }) + try { + yield { type: 'ready', clientId } + while (!signal.aborted) { + while (connection.items.length > 0) { + const item = connection.items.shift() as EventStreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + yield item.value + } + if (signal.aborted) return + await new Promise((resolve) => { connection.wake = resolve }) + connection.wake = undefined + } + } finally { + signal.removeEventListener('abort', abort) + this.connections.delete(connection) + } + } +} + +async function eventBench( + call: ConnectionHandle['rpc']['call'] = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }), +): Promise<{ + readonly ctx: Context + readonly client: Fiber + readonly carrier: RemoteEventCarrier + readonly generation: GenerationHarness + readonly run: GenerationRun + readonly call: ConnectionHandle['rpc']['call'] +}> { + const carrier = new RemoteEventCarrier() + const { ctx, client, generation } = await benchFiber( + call, + 'in-process', + carrier.open, + ) + const run = generation.start() + await run.ready + return { ctx, client, carrier, generation, run, call } +} + +function approvalFrame(eventId: string, agentId: string, prompt: string): object { + return { + type: 'waterfall', + event: 'fixture/approval', + eventId, + agentId, + request: { prompt }, + } +} + +describe('Client Remote transport readiness', () => { + it('creates logical stream supervisors against the installed Connection', async () => { + const { ctx, client } = await benchFiber(vi.fn()) + const stream = ctx.remote.$stream({ + name: 'fixture stream', + open: () => unexpectedInProcessStream(), + ended: () => new Error('fixture stream ended'), + }) + + expect(stream).toBeInstanceOf(RemoteStream) + await stream.dispose() + await client.dispose() + }) + + it('starts after Loader settlement and stops the owned loop on disposal', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + expect(start).not.toHaveBeenCalled() + + readiness.resolve() + await vi.waitFor(() => { expect(start).toHaveBeenCalledTimes(1) }) + + await client.dispose() + expect(stop).toHaveBeenCalledTimes(1) + }) + + it('does not start when disposal wins the Loader-settlement race', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + + await client.dispose() + readiness.resolve() + await Promise.resolve() + + expect(start).not.toHaveBeenCalled() + expect(stop).not.toHaveBeenCalled() + }) + + it('leaves the transport stopped when Loader settlement rejects', async () => { + const readiness = deferredReadiness() + const { client, start, stop } = await loaderReadinessBench(readiness.promise) + + readiness.reject(new Error('fixture Loader failed')) + await Promise.resolve() + await Promise.resolve() + + expect(start).not.toHaveBeenCalled() + await client.dispose() + expect(stop).not.toHaveBeenCalled() + }) +}) + describe('Client Typert API', () => { - it('mounts concrete direct methods, validates both boundaries, and withdraws retained handles', async () => { + it('mounts concrete direct methods, validates inputs, and withdraws retained handles', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) @@ -210,12 +625,8 @@ describe('Client Typert API', () => { call.mockResolvedValueOnce({ ok: true, value: { ref: 1 } }) await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).resolves.toEqual({ - ok: false, - error: { - code: 'internal', - message: 'client api: probe/create failed: client api: probe/create rejected "result"', - details: {}, - }, + ok: true, + value: { ref: 1 }, }) await assembly.dispose() @@ -271,6 +682,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }), @@ -301,6 +713,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-2' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-2' ? agentCtx : undefined, }) const assembly = ctx.plugin(Object.assign( (scope: Context) => scope.remote.$mount({ package: '@fixture/probe', descriptors: [contextDescriptor()] }), @@ -323,15 +736,15 @@ describe('Client Typert API', () => { expect(ctx.get('remote.probe')).toBeUndefined() }) - it('rejects weak descriptors and namespace collisions before registration', async () => { + it('accepts weak result codecs and rejects namespace collisions before registration', async () => { const ctx = await bench(vi.fn()) const weak: InvocationDescriptor = { ...directDescriptor(), result: { mode: 'src-json' }, } - await expect(ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] })) - .rejects.toThrow('has no strict codec') + const disposeWeak = await ctx.remote.$mount({ package: '@fixture/weak', descriptors: [weak] }) + await disposeWeak() await expect(ctx.remote.$mount({ package: '@fixture/conflict', descriptors: [{ ...directDescriptor(), namespace: '$mount' }], @@ -346,6 +759,7 @@ describe('Client Typert API', () => { const agentCtx = ctx.extend({ fixtureId: 'agent-remounted' }) as FixtureContext ctx.typert.contexts.registerClient('fixture', { identity: candidate => (candidate as Context & { fixtureId?: string }).fixtureId, + resolve: id => id === 'agent-remounted' ? agentCtx : undefined, }) const direct = directDescriptor() const context = contextDescriptor() @@ -432,6 +846,42 @@ describe('Client Typert API', () => { await retry() }) + it('rolls back earlier namespaces when a later namespace fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...first } = directDescriptor() + const second: InvocationDescriptor = { + ...first, + id: '@fixture/archive#archive/store', + namespace: 'archive', + method: 'store', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'store') throw new Error('fixture later-namespace failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/failing-namespaces', + descriptors: [first, second], + })).rejects.toThrow('fixture later-namespace failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect((ctx.remote as unknown as Record).archive).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + + const retry = await ctx.remote.$mount({ + package: '@fixture/retry-namespaces', + descriptors: [first, second], + }) + expect(ctx.remote.probe.create).toBeTypeOf('function') + expect((ctx.remote as unknown as Record>).archive?.store).toBeTypeOf('function') + await retry() + }) + it('rolls back a direct projection when its scoped projection fails to install', async () => { const ctx = await bench(vi.fn()) const disposeContext = await ctx.remote.$mount({ @@ -458,6 +908,91 @@ describe('Client Typert API', () => { await disposeContext() }) + it('unwinds an already-installed namespace when a later namespace fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...probe } = directDescriptor() + const vault: InvocationDescriptor = { + ...probe, + id: '@fixture/vault#vault/seal', + service: 'vault', + namespace: 'vault', + method: 'seal', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'seal') throw new Error('fixture later-namespace failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ package: '@fixture/two-namespaces', descriptors: [probe, vault] })) + .rejects.toThrow('fixture later-namespace failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + expect((ctx.remote as unknown as Record).vault).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + }) + + it('rolls back an earlier scoped projection when a later descriptor fails to install', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...direct } = directDescriptor() + const failing: InvocationDescriptor = { + ...direct, + id: '@fixture/probe#probe/archive', + method: 'archive', + } + const defineProperty = Object.defineProperty + const spy = vi.spyOn(Object, 'defineProperty').mockImplementation((target, key, attributes) => { + if (key === 'archive') throw new Error('fixture trailing failure') + return defineProperty(target, key, attributes) + }) + try { + await expect(ctx.remote.$mount({ + package: '@fixture/scoped-then-failing', + descriptors: [contextDescriptor(), failing], + })).rejects.toThrow('fixture trailing failure') + } finally { + spy.mockRestore() + } + + expect((ctx.remote as unknown as Record).probe).toBeUndefined() + await vi.waitFor(() => { expect(ctx.typert.remotes.list()).toEqual([]) }) + }) + + it('keeps a namespace another contribution still populates when a group leaves', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: { renamed: true } }) + const ctx = await bench(call) + const { scope: _scope, ...direct } = directDescriptor() + const disposeDirect = await ctx.remote.$mount({ package: '@fixture/direct-owner', descriptors: [direct] }) + const disposeScoped = await ctx.remote.$mount({ package: '@fixture/scoped-owner', descriptors: [contextDescriptor()] }) + + await disposeDirect() + // The namespace survives its first group: the second contribution still owns methods on it. + const surviving = ctx.get('remote.probe') as unknown as Record | undefined + expect(surviving).toBeDefined() + expect(surviving?.create).toBeUndefined() + await disposeScoped() + expect(ctx.get('remote.probe')).toBeUndefined() + }) + + it('unparks a namespace dependent only after its contribution methods exist', async () => { + const ctx = await bench(vi.fn()) + const { scope: _scope, ...direct } = directDescriptor() + let observed: string | undefined + // Parked before the mount: the unpark moment is the observation — the + // atomic-visibility guarantee says the service never appears methodless. + const parked = ctx.inject(['remote.probe'], (probeCtx) => { + observed = typeof (probeCtx.get('remote.probe') as { create?: unknown } | undefined)?.create + }) + const dispose = await ctx.remote.$mount({ package: '@fixture/atomic-visibility', descriptors: [direct] }) + await parked + expect(observed).toBe('function') + await dispose() + }) + it('rejects weak parameter and Context codecs plus malformed scope projections', async () => { const ctx = await bench(vi.fn()) const direct = directDescriptor() @@ -494,7 +1029,7 @@ describe('Client Typert API', () => { })).rejects.toThrow('scope must select its only lookup parameter') }) - it('validates invocation arity, required binders, live Connection, and mutable descriptor codecs', async () => { + it('validates invocation arity, required adapters, live Connection, and mutable descriptor codecs', async () => { const call = vi.fn() .mockResolvedValue({ ok: true, value: { ref: 'goal-1' } }) const ctx = await bench(call) @@ -514,7 +1049,7 @@ describe('Client Typert API', () => { await expect((ctx as FixtureContext).remote.probe.create({ objective: 'ship' })) .rejects.toThrow('expected 2 business argument(s)') await expect((ctx as FixtureContext).remote.probe.rename({ objective: 'ship' })) - .rejects.toThrow('no Client Context binder') + .rejects.toThrow('no Client Context adapter') ;(descriptor.parameters[0] as { codec: { mode: string } }).codec.mode = 'src-json' await expect(ctx.remote.probe.create('agent-1', { objective: 'ship' })).rejects.toThrow('has no strict codec') @@ -555,6 +1090,28 @@ describe('Client Typert API', () => { expect((ctx.remote as unknown as Record).probe).toBeUndefined() }) + it('keeps a namespace while another contribution still owns a method', async () => { + const ctx = await bench(vi.fn()) + const disposeCreate = await ctx.remote.$mount({ + package: '@fixture/create-contribution', + descriptors: [directDescriptor()], + }) + const disposeMaybe = await ctx.remote.$mount({ + package: '@fixture/maybe-contribution', + descriptors: [maybeDescriptor()], + }) + const namespace = ctx.get('remote.probe') as unknown as Record + + await disposeCreate() + + expect(ctx.get('remote.probe') !== undefined).toBe(true) + expect(namespace.create).toBeUndefined() + expect(namespace.maybe).toBeTypeOf('function') + + await disposeMaybe() + expect(ctx.get('remote.probe')).toBeUndefined() + }) + it('fails a method obtained from a withdrawn namespace getter', async () => { const ctx = await bench(vi.fn()) const dispose = await ctx.remote.$mount({ package: '@fixture/probe', descriptors: [directDescriptor()] }) @@ -719,7 +1276,7 @@ describe('Client Typert API', () => { }) it('owns each $on subscription in the calling fiber', async () => { - const { ctx, client } = await benchFiber(vi.fn()) + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] const subscriber = ctx.plugin(Object.assign( (scope: Context) => { scope.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) }, @@ -727,103 +1284,1098 @@ describe('Client Typert API', () => { )) await subscriber - ctx.remote.$dispatch('fixture/changed', ['settings']) - expect(seen).toEqual(['settings']) + expect(carrier.calls).toEqual([expect.objectContaining({ + channel: '/api', endpoint: '$events', payload: { args: {} }, + })]) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['settings'] }) + await vi.waitFor(() => { expect(seen).toEqual(['settings']) }) await subscriber.dispose() - ctx.remote.$dispatch('fixture/changed', ['after fiber disposal']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['after fiber disposal'] }) + await Promise.resolve() expect(seen).toEqual(['settings']) await client.dispose() expect(ctx.get('remote')).toBeUndefined() }) - it('isolates a throwing listener from the rest of the same event', async () => { - const ctx = await bench(vi.fn()) + it('isolates throwing and rejected notification listeners', async () => { + const { ctx, client, carrier } = await eventBench() const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) const seen: string[] = [] - const disposeFirst = ctx.remote.$on('fixture/changed', () => { - throw new Error('fixture listener failure') - }) - ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - try { - ctx.remote.$dispatch('fixture/changed', ['credentials']) - - expect(seen).toEqual(['credentials']) - expect(consoleError).toHaveBeenCalledWith( - 'client api: Remote event "fixture/changed" listener threw:', - expect.any(Error), - ) - disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['commands']) - expect(seen).toEqual(['credentials', 'commands']) - expect(consoleError).toHaveBeenCalledTimes(1) - } finally { - consoleError.mockRestore() + const failingListener = (namespace: string): unknown => { + if (namespace === 'sync') throw new Error('fixture listener failure') + return Promise.reject(new Error('fixture async failure')) } - }) - - it('contains an async listener whose promise rejects', async () => { - const ctx = await bench(vi.fn()) - const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const seen: string[] = [] - // The declared return is void, so nobody awaits an async listener: the - // rejection has to be contained here or it escapes as an unhandled one. - ctx.remote.$on('fixture/changed', () => Promise.reject(new Error('fixture async failure'))) // oxlint-disable-line typescript/no-misused-promises + const disposeThrowing = ctx.remote.$on('fixture/changed', failingListener) ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) try { - ctx.remote.$dispatch('fixture/changed', ['credentials']) - await Promise.resolve() - await Promise.resolve() + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['sync'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync']) }) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['async'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async']) }) + expect(consoleError).toHaveBeenCalledTimes(2) - expect(seen).toEqual(['credentials']) - expect(consoleError).toHaveBeenCalledWith( - 'client api: Remote event "fixture/changed" listener threw:', - expect.any(Error), - ) + disposeThrowing() + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] }) + await vi.waitFor(() => { expect(seen).toEqual(['sync', 'async', 'survivor']) }) } finally { consoleError.mockRestore() + await client.dispose() } }) it('retires only its own registration when one listener subscribes twice', async () => { - const ctx = await bench(vi.fn()) + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] - // One function object, two registrations. A table keyed by listener identity - // stores it once, so the first frame would reach it once instead of twice - // and either disposer would silence both. const listener = (namespace: string): void => { seen.push(namespace) } const disposeFirst = ctx.remote.$on('fixture/changed', listener) ctx.remote.$on('fixture/changed', listener) - ctx.remote.$dispatch('fixture/changed', ['both']) - expect(seen).toEqual(['both', 'both']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['both'] }) + await vi.waitFor(() => { expect(seen).toEqual(['both', 'both']) }) - // The surviving registration keeps receiving after its twin retires. disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['survivor']) - expect(seen).toEqual(['both', 'both', 'survivor']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['survivor'] }) + await vi.waitFor(() => { expect(seen).toEqual(['both', 'both', 'survivor']) }) - // Disposing twice is inert: the record is already gone, so the second call - // must not splice the surviving twin out from under its own owner. disposeFirst() - ctx.remote.$dispatch('fixture/changed', ['still here']) - expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['still here'] }) + await vi.waitFor(() => { + expect(seen).toEqual(['both', 'both', 'survivor', 'still here']) + }) + await client.dispose() }) - it('separates the consumer verb from the carrier handoff', () => { + it('keeps the carrier handoff private', () => { expectTypeOf().toHaveProperty('$on') - // The carrier owning the frame sink calls this; a consumer subscribes instead. - expectTypeOf().toHaveProperty('$dispatch') + expectTypeOf<'$dispatch' extends keyof ClientRemote ? true : false>().toEqualTypeOf() }) - it('drops a forwarded event nobody subscribes to', async () => { - const ctx = await bench(vi.fn()) + it('drops an unobserved notification and accepts a null-prototype frame', async () => { + const { ctx, client, carrier } = await eventBench() const seen: string[] = [] ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) - ctx.remote.$dispatch('fixture/idle', [1]) + carrier.emit({ type: 'emit', event: 'fixture/idle', args: [1] }) + carrier.emit(Object.assign(Object.create(null) as Record, { + type: 'emit', + event: 'fixture/changed', + args: ['null prototype'], + })) + await vi.waitFor(() => { expect(seen).toEqual(['null prototype']) }) + await client.dispose() + }) + + it('delegates immediately when the Agent adapter or Context is unavailable', async () => { + const { ctx, client, carrier, call } = await eventBench() + carrier.emit(approvalFrame('event-no-adapter', 'agent-late', 'no adapter')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + + const target = ctx.extend() + const resolve = vi.fn((id: unknown) => id === 'agent-found' ? target : undefined) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-found') : undefined, + resolve, + }) + carrier.emit(approvalFrame('event-missing-context', 'agent-missing', 'missing')) + carrier.emit(approvalFrame('event-no-listener', 'agent-found', 'delegate')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(3) }) + expect(resolve).toHaveBeenCalledTimes(2) + for (const eventId of ['event-no-adapter', 'event-missing-context', 'event-no-listener']) { + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { args: { clientId: 'event-client-1', eventId, outcome: { kind: 'next' } } }, + expect.any(AbortSignal), + ) + } + await client.dispose() + }) + + it('reports Agent Context resolution failures and delegates', async () => { + const { ctx, client, carrier, call } = await eventBench() + ctx.typert.contexts.registerClient('agent', { + identity: () => undefined, + resolve: () => { throw new Error('fixture Context lookup failed') }, + }) + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + carrier.emit(approvalFrame('event-resolve-error', 'agent-error', 'resolve')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(consoleError).toHaveBeenCalledWith( + 'client api: Remote event "fixture/approval" listener threw:', + expect.objectContaining({ message: 'fixture Context lookup failed' }), + ) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-resolve-error', + outcome: { kind: 'next' }, + }, + }, + expect.any(AbortSignal), + ) + } finally { + consoleError.mockRestore() + await client.dispose() + } + }) + + it('normalizes undefined waterfall results and rejects non-JSON results', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-results') : undefined, + resolve: id => id === 'agent-results' ? target : undefined, + }) + target.remote.$on('fixture/approval', async request => request.prompt === 'undefined' + ? undefined as unknown as FixtureApprovalOutcome + : Symbol('not JSON') as unknown as FixtureApprovalOutcome) + + carrier.emit(approvalFrame('event-undefined', 'agent-results', 'undefined')) + carrier.emit(approvalFrame('event-invalid', 'agent-results', 'invalid')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(2) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { args: { clientId: 'event-client-1', eventId: 'event-undefined', outcome: { kind: 'result' } } }, + expect.any(AbortSignal), + ) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-invalid', + outcome: { + kind: 'rejected', + error: { + name: 'TypeError', + message: 'Remote event listener result is not lossless JSON data', + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('fails the Connection generation when a result RPC is rejected', async () => { + const call = vi.fn().mockResolvedValue({ + ok: false, + error: { code: 'internal', message: 'fixture result rejected', details: {} }, + }) + const { client, carrier, run } = await eventBench(call) + + carrier.emit(approvalFrame('event-result-rejected', 'agent-missing', 'respond')) + + await expect(run.done).rejects.toThrow('fixture result rejected') + await client.dispose() + }) + + it('filters scoped waterfall listeners and returns the first claimed result', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend({ + [Context.filter](candidate: Context): boolean { + const tag = (candidate as Context & { [fixtureContextTag]?: string })[fixtureContextTag] + return tag === undefined || tag === 'agent-1' + }, + }) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-1') : undefined, + resolve: id => id === 'agent-1' ? target : undefined, + }) + const matching = ctx.extend({ [fixtureContextTag]: 'agent-1' }) + const excluded = ctx.extend({ [fixtureContextTag]: 'agent-2' }) + const seen: string[] = [] + ctx.remote.$on('fixture/approval', async function (request, next) { + expect(this).toBe(target) + expect(request.agent).toBe(target) + expect(request.signal).toBeInstanceOf(AbortSignal) + seen.push('root') + return next() + }) + matching.remote.$on('fixture/approval', async (_request, next) => { + seen.push('matching-next') + return next() + }) + excluded.remote.$on('fixture/approval', async () => { + seen.push('excluded') + return 'unavailable' + }) + matching.remote.$on('fixture/approval', async () => { + seen.push('matching-result') + return 'allowed' + }) + + carrier.emit(approvalFrame('event-1', 'agent-1', 'ship')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(seen).toEqual(['root', 'matching-next', 'matching-result']) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-1', + outcome: { kind: 'result', value: 'allowed' }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('returns a scoped listener rejection to the Host', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-rejected') : undefined, + resolve: id => id === 'agent-rejected' ? target : undefined, + }) + const rejection = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }) + target.remote.$on('fixture/approval', () => Promise.reject(rejection)) + + carrier.emit(approvalFrame('event-rejected', 'agent-rejected', 'cancelled')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-rejected', + outcome: { + kind: 'rejected', + error: { + name: 'UserQuestionError', + message: 'the user cancelled ask_user_question', + code: 'ASK_CANCELLED', + details: { questionId: 'question-1' }, + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('returns Context-filter failures as rejections', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, carrier } = await eventBench(call) + const target = ctx.extend({ + [Context.filter](): boolean { + throw new Error('fixture Context filter failed') + }, + }) + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-filter-failure') : undefined, + resolve: id => id === 'agent-filter-failure' ? target : undefined, + }) + ctx.remote.$on('fixture/approval', async (_request, next) => next()) + + carrier.emit(approvalFrame('event-filter-failure', 'agent-filter-failure', 'filter')) + + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'event-client-1', + eventId: 'event-filter-failure', + outcome: { + kind: 'rejected', + error: { + name: 'Error', + message: 'fixture Context filter failed', + }, + }, + }, + }, + expect.any(AbortSignal), + ) + await client.dispose() + }) + + it('cancels a pending Client listener without returning a late result', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-cancel') : undefined, + resolve: id => id === 'agent-cancel' ? target : undefined, + }) + const entered = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + const signal = request.signal as AbortSignal + entered.resolve(signal) + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return 'allowed' + }) + carrier.emit(approvalFrame('event-cancel', 'agent-cancel', 'wait')) + const deliverySignal = await entered.promise + + carrier.emit({ type: 'cancel', eventId: 'event-cancel' }) + await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) }) + await Promise.resolve() + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('drops a settled listener result when cancellation wins before reply', async () => { + const { ctx, client, carrier, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-cancel-race') : undefined, + resolve: id => id === 'agent-cancel-race' ? target : undefined, + }) + const entered = Promise.withResolvers() + const release = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + entered.resolve(request.signal as AbortSignal) + await release.promise + return 'allowed' + }) + carrier.emit(approvalFrame('event-cancel-race', 'agent-cancel-race', 'wait')) + const deliverySignal = await entered.promise + + release.resolve(undefined) + carrier.emit({ type: 'cancel', eventId: 'event-cancel-race' }) + await vi.waitFor(() => { expect(deliverySignal.aborted).toBe(true) }) + await Promise.resolve() + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('cancels pending listener work when the generation ends', async () => { + const { ctx, client, carrier, run, call } = await eventBench() + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-generation') : undefined, + resolve: id => id === 'agent-generation' ? target : undefined, + }) + const entered = Promise.withResolvers() + target.remote.$on('fixture/approval', async (request) => { + const signal = request.signal as AbortSignal + entered.resolve(signal) + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + return 'allowed' + }) + carrier.emit(approvalFrame('event-generation', 'agent-generation', 'wait')) + const deliverySignal = await entered.promise + + run.abort(new Error('fixture generation ended')) + await expect(run.done).resolves.toBeUndefined() + expect(deliverySignal.aborted).toBe(true) + expect(call).not.toHaveBeenCalled() + + await client.dispose() + }) + + it('contains a result transport failure after the generation is cancelled', async () => { + const response = Promise.withResolvers() + const call = vi.fn(() => response.promise) + const { client, carrier, run } = await eventBench(call) + carrier.emit(approvalFrame('event-late-result', 'agent-missing', 'respond')) + await vi.waitFor(() => { expect(call).toHaveBeenCalledOnce() }) + + run.abort(new Error('fixture generation cancelled')) + response.reject(new Error('fixture late result failure')) + await expect(run.done).resolves.toBeUndefined() + + await client.dispose() + }) + + it('normalizes a non-Error result transport failure', async () => { + const call = vi.fn().mockRejectedValue('fixture transport failure') + const { client, carrier, run } = await eventBench(call) + + carrier.emit(approvalFrame('event-result-throw', 'agent-missing', 'respond')) + + await expect(run.done).rejects.toMatchObject({ + message: 'client api: Remote event result delivery failed', + cause: 'fixture transport failure', + }) + await client.dispose() + }) + + it('keeps the newer generation tracked when an overlapping generation settles', async () => { + const { client, generation, run } = await eventBench() + const overlapping = generation.startOverlapping() + await overlapping.ready + + run.abort(new Error('fixture older generation ended')) + await expect(run.done).resolves.toBeUndefined() + overlapping.abort(new Error('fixture newer generation ended')) + await expect(overlapping.done).resolves.toBeUndefined() + await client.dispose() + }) + + it('opens the forwarded-event stream on the browser Remote mux', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const call = vi.fn() + .mockResolvedValue({ ok: true, value: undefined }) + const { ctx, client, generation } = await benchFiber(call, 'web') + const seen: string[] = [] + const target = ctx.extend() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => candidate === target ? agentId('agent-browser') : undefined, + resolve: id => id === 'agent-browser' ? target : undefined, + }) + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + target.remote.$on('fixture/approval', async function (request) { + expect(this).toBe(target) + expect(request.agent).toBe(this) + expect(request.signal).toBeInstanceOf(AbortSignal) + return 'allowed' + }) + const run = generation.start() + + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + expect(opened).toMatchObject({ + type: 'open', endpoint: '$events', payload: { args: {} }, + }) + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { type: 'ready', clientId: 'browser-client' }, + }) + await run.ready + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { type: 'emit', event: 'fixture/changed', args: ['browser'] }, + }) + await vi.waitFor(() => { expect(seen).toEqual(['browser']) }) + + socket.receive({ + type: 'item', + streamId: opened.streamId, + value: { + type: 'waterfall', + event: 'fixture/approval', + eventId: 'event-browser', + agentId: 'agent-browser', + request: { prompt: 'browser approval' }, + }, + }) + await vi.waitFor(() => { expect(call).toHaveBeenCalledTimes(1) }) + expect(socket.sent).toHaveLength(1) + expect(call).toHaveBeenCalledWith( + '/api', + '$events/result', + { + args: { + clientId: 'browser-client', + eventId: 'event-browser', + outcome: { kind: 'result', value: 'allowed' }, + }, + }, + expect.any(AbortSignal), + ) + + await client.dispose() + }) + }) + + it('publishes the Fixture Host description after Remote events report ready', async () => { + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { hostname: '127.0.0.1', search: '?fixture' }, + }) + const ctx = new Context() + try { + await ctx.plugin(TypertRegistry) + await ctx.plugin({ inject: [], apply: applyConnection }) + await ctx.plugin({ inject, apply }) + const connection = ctx.get('connection') as ConnectionHandle | undefined + if (connection === undefined) throw new Error('fixture Connection service is unavailable') + + await vi.waitFor(() => { + expect(connection.hostDescription.getSnapshot()?.home).toBe('/home/fixture') + }) + } finally { + await ctx.fiber.dispose() + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } + }) + + it.each([ + null, + [], + {}, + { type: 'pending' }, + { type: 'ready' }, + { type: 'ready', clientId: '' }, + { type: 'ready', clientId: 'client', extra: true }, + { type: 'emit', event: 'fixture/changed', args: ['too early'] }, + ])('rejects malformed forwarded-event readiness item %#', async (opening) => { + const open: NonNullable = () => (async function *() { + yield opening + })() + const { client, generation } = await benchFiber( + vi.fn(), + 'in-process', + open, + ) + const run = generation.start() + try { + await expect(run.done).rejects.toThrow('forwarded Remote event stream did not begin with ready') + } finally { + await client.dispose() + } + }) + + it('propagates physical carrier failure and opens events for the replacement generation', async () => { + const { ctx, client, carrier, generation, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + expect(carrier.calls).toHaveLength(1) + + carrier.fail(new RemoteStreamCarrierError('fixture generation lost')) + await expect(run.done).rejects.toThrow('fixture generation lost') + const replacement = generation.start() + await replacement.ready + await vi.waitFor(() => { expect(carrier.calls).toHaveLength(2) }) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['replacement'] }) + await vi.waitFor(() => { expect(seen).toEqual(['replacement']) }) + + await client.dispose() + }) + + it.each([ + { + name: 'Host failure', + stop: (carrier: RemoteEventCarrier) => { + carrier.fail(new RemoteStreamError('internal', 'fixture Host failed', {})) + }, + message: 'fixture Host failed', + }, + { + name: 'normal end', + stop: (carrier: RemoteEventCarrier) => { carrier.end() }, + message: 'forwarded Remote event stream ended unexpectedly', + }, + ])('fails the active generation after $name', async ({ stop, message }) => { + const { ctx, client, carrier, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + stop(carrier) + await expect(run.done).rejects.toThrow(message) + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] }) + await Promise.resolve() + expect(carrier.calls).toHaveLength(1) expect(seen).toEqual([]) + await client.dispose() + }) + + it.each([ + 'not an object', + null, + [], + {}, + { type: 'unknown' }, + { type: 'emit', event: 'fixture/changed' }, + { type: 'emit', event: 'fixture/changed', args: [], extra: true }, + { type: 'emit', event: 1, args: [] }, + { type: 'emit', event: '', args: [] }, + { type: 'emit', event: 'fixture/changed', args: {} }, + { type: 'emit', event: 'fixture/changed', args: [1n] }, + { type: 'waterfall', event: 'fixture/approval', eventId: '', agentId: 'agent-1', request: {} }, + { type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: '', request: {} }, + { + type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { agent: null }, + }, + { + type: 'waterfall', event: 'fixture/approval', eventId: 'event-1', agentId: 'agent-1', request: { signal: null }, + }, + { type: 'cancel', eventId: '' }, + { type: 'cancel', eventId: 'event-1', extra: true }, + ])('rejects malformed forwarded-event frame %# and stops that stream', async (frame) => { + const { ctx, client, carrier, run } = await eventBench() + const seen: string[] = [] + ctx.remote.$on('fixture/changed', (namespace) => { seen.push(namespace) }) + carrier.emit(frame) + await expect(run.done).rejects.toThrow('client api: invalid forwarded Remote event frame') + carrier.emit({ type: 'emit', event: 'fixture/changed', args: ['too late'] }) + await Promise.resolve() + expect(carrier.calls).toHaveLength(1) + expect(seen).toEqual([]) + await client.dispose() + }) + + it('aborts and awaits forwarded-event delivery during disposal', async () => { + const { ctx, client, carrier, run } = await eventBench() + ctx.remote.$on('fixture/changed', () => {}) + expect(carrier.activeConnections).toBe(1) + const signal = carrier.calls[0]?.signal + + await client.dispose() + await expect(run.done).resolves.toBeUndefined() + + expect(signal?.aborted).toBe(true) + expect(carrier.activeConnections).toBe(0) + expect(ctx.get('remote')).toBeUndefined() + }) + + it('rejects a generation when its Connection has been withdrawn', async () => { + const carrier = new RemoteEventCarrier() + const { ctx, client, generation } = await benchFiber( + vi.fn(), + 'in-process', + carrier.open, + ) + ctx.set('connection', undefined) + const run = generation.start() + await expect(run.done).rejects.toThrow('$events has no active Connection') + expect(carrier.calls).toEqual([]) + await client.dispose() + }) + + it('guards stream iteration across mount and Connection withdrawal', async () => { + const call = vi.fn() + const ctx = await bench(call) + const firstDispose = await ctx.remote.$mount({ + package: '@fixture/stream-first', descriptors: [streamDescriptor()], + }) + const withdrawn = ctx.remote.probe.watch('withdrawn')[Symbol.asyncIterator]() + await firstDispose() + await expect(withdrawn.next()).rejects.toThrow('Remote method probe/watch is no longer mounted') + + const secondDispose = await ctx.remote.$mount({ + package: '@fixture/stream-second', descriptors: [streamDescriptor()], + }) + ctx.set('connection', undefined) + await expect(ctx.remote.probe.watch('offline')[Symbol.asyncIterator]().next()) + .rejects.toThrow('probe/watch has no active Connection') + + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let markStarted!: () => void + const started = new Promise((resolve) => { markStarted = resolve }) + const source = async function *(): AsyncIterable { + markStarted() + await released + yield 'late item' + } + ctx.set('connection', { + rpc: { call, open: () => source() }, + } as unknown as ConnectionHandle) + const active = ctx.remote.probe.watch('active')[Symbol.asyncIterator]() + const pending = active.next() + await started + await secondDispose() + release() + await expect(pending).rejects.toThrow('Remote method probe/watch is no longer mounted') + }) + + it('publishes a namespace only after every contributed method is installed', async () => { + const ctx = await bench(vi.fn()) + let visible: string[] | undefined + const consumer = ctx.plugin({ + inject: ['remote.probe'], + apply(scope) { + const namespace = scope.get('remote.probe') as unknown as Record + visible = [typeof namespace.watch, typeof namespace.archive] + }, + }) + const archive: InvocationDescriptor = { + ...streamDescriptor(), + id: '@fixture/probe#probe/archive', + method: 'archive', + } + + const dispose = await ctx.remote.$mount({ + package: '@fixture/atomic-namespace', + descriptors: [streamDescriptor(), archive], + }) + await consumer.await() + + expect(visible).toEqual(['function', 'function']) + await dispose() + }) + + it('normalizes worker-local structural stream failures without sharing class identity', async () => { + const cases = [{ + failure: Object.assign(new Error('fixture Host rejected the stream'), { + dshRemoteStreamFailure: { + kind: 'remote' as const, + code: 'fixture-rejected', + details: { retry: false }, + }, + }), + assert: (error: unknown) => { + expect(error).toBeInstanceOf(RemoteStreamError) + expect(error).toMatchObject({ + code: 'fixture-rejected', + message: 'fixture Host rejected the stream', + details: { retry: false }, + }) + }, + }, { + failure: Object.assign(new Error('worker carrier stopped'), { + dshRemoteStreamFailure: { kind: 'carrier' as const }, + }), + assert: (error: unknown) => { + expect(error).toBeInstanceOf(RemoteStreamCarrierError) + expect(error).toMatchObject({ message: 'worker carrier stopped' }) + }, + }, { + failure: 'caller abort sentinel', + assert: (error: unknown) => { expect(error).toBe('caller abort sentinel') }, + }] + + for (const testCase of cases) { + const open: NonNullable = () => (async function *(): AsyncGenerator { + throw testCase.failure + })() + const { ctx, client } = await benchFiber( + vi.fn(), + 'in-process', + open, + ) + const dispose = await ctx.remote.$mount({ package: '@fixture/worker-stream', descriptors: [streamDescriptor()] }) + try { + const error = await ctx.remote.probe.watch('failure')[Symbol.asyncIterator]().next() + .then(() => undefined, (reason: unknown) => reason) + testCase.assert(error) + } finally { + await dispose() + await client.dispose() + } + } + }) + + it('multiplexes Remote streams without using the Connection RPC caller', async () => { + const originalWebSocket = globalThis.WebSocket + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + Object.defineProperty(globalThis, 'location', { + configurable: true, + value: { origin: 'https://harness.example' }, + }) + FakeWebSocket.sockets.length = 0 + const call = vi.fn() + const ctx = await bench(call, 'web') + expect(FakeWebSocket.sockets).toHaveLength(1) + const dispose = await ctx.remote.$mount({ package: '@fixture/stream', descriptors: [streamDescriptor()] }) + try { + const first = ctx.remote.probe.watch('alpha')[Symbol.asyncIterator]() + const firstItem = first.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('wss://harness.example/api/remote.mux') + const opened = JSON.parse(socket.sent[0]!) as { streamId: string } + expect(opened).toMatchObject({ + type: 'open', + endpoint: 'probe/watch', + payload: { args: { topic: 'alpha' } }, + }) + socket.receive({ type: 'item', streamId: opened.streamId, value: 'alpha:one' }) + await expect(firstItem).resolves.toEqual({ done: false, value: 'alpha:one' }) + const firstEnd = first.next() + socket.receive({ type: 'end', streamId: opened.streamId }) + await expect(firstEnd).resolves.toEqual({ done: true, value: undefined }) + + const failed = ctx.remote.probe.watch('failure')[Symbol.asyncIterator]() + const failedItem = failed.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const failedOpen = JSON.parse(socket.sent[1]!) as { streamId: string } + socket.receive({ + type: 'error', + streamId: failedOpen.streamId, + error: { + code: 'lookup-unavailable', + message: 'fixture stream failed', + details: { lookup: 'missing' }, + }, + }) + await expect(failedItem).rejects.toMatchObject({ + name: 'RemoteStreamError', + code: 'lookup-unavailable', + message: 'fixture stream failed', + details: { lookup: 'missing' }, + }) + + const abort = new AbortController() + const cancelled = ctx.remote.probe.watch('cancel', abort.signal)[Symbol.asyncIterator]() + const cancelledItem = cancelled.next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(3) }) + const cancelledOpen = JSON.parse(socket.sent[2]!) as { streamId: string } + const cancellation = new Error('caller cancelled') + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'already queued' }) + abort.abort(cancellation) + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'after cancellation' }) + await expect(cancelledItem).rejects.toBe(cancellation) + await vi.waitFor(() => { + expect(socket.sent.map(text => JSON.parse(text) as unknown)).toContainEqual({ + type: 'cancel', streamId: cancelledOpen.streamId, + }) + }) + expect(call).not.toHaveBeenCalled() + } finally { + await dispose() + await ctx.fiber.dispose() + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } }) }) + +describe('Remote stream client carrier lifecycle', () => { + it('connects without a logical stream, reconnects after failures, and stops permanently', async () => { + await withFakeWebSocket('https://harness.example', async () => { + FakeWebSocket.autoOpen = false + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const client = new RemoteStreamMuxClient() + client.start() + client.start() + expect(FakeWebSocket.sockets).toHaveLength(1) + + const failed = FakeWebSocket.sockets[0]! + failed.fail() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(2) + + const connected = FakeWebSocket.sockets[1]! + connected.open() + await vi.advanceTimersByTimeAsync(0) + expect(connected.sent).toEqual([]) + connected.fail() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(3) + + const replacement = FakeWebSocket.sockets[2]! + replacement.open() + replacement.drop() + await vi.advanceTimersByTimeAsync(500) + expect(FakeWebSocket.sockets).toHaveLength(4) + + const final = FakeWebSocket.sockets[3]! + final.open() + await vi.advanceTimersByTimeAsync(0) + await client.close() + await client.close() + client.start() + await expect(client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next()).rejects.toThrow('Remote stream client disposed') + await vi.advanceTimersByTimeAsync(20_000) + + expect(FakeWebSocket.sockets).toHaveLength(4) + expect(final.closedWith).toContainEqual({ code: 1000, reason: 'disposed' }) + expect(warn).toHaveBeenCalledTimes(3) + + const stopping = new RemoteStreamMuxClient() + stopping.start() + const racing = FakeWebSocket.sockets[4]! + racing.open() + racing.drop() + await stopping.close() + await vi.advanceTimersByTimeAsync(20_000) + expect(FakeWebSocket.sockets).toHaveLength(5) + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('shares an in-flight connection and uses the internal ws URL without a browser origin', async () => { + await withFakeWebSocket(undefined, async () => { + FakeWebSocket.autoOpen = false + const client = new RemoteStreamMuxClient() + const first = client.open('feed/follow', { label: 'first' }, new AbortController().signal) + [Symbol.asyncIterator]() + const second = client.open('feed/follow', { label: 'second' }, new AbortController().signal) + [Symbol.asyncIterator]() + const firstPending = first.next() + const secondPending = second.next() + expect(FakeWebSocket.sockets).toHaveLength(1) + const socket = FakeWebSocket.sockets[0]! + expect(socket.url).toBe('ws://dsh.internal/api/remote.mux') + + socket.open() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const streamIds = socket.sent.map(text => (JSON.parse(text) as { streamId: string }).streamId) + socket.receive({ type: 'end', streamId: streamIds[0] }) + socket.receive({ type: 'end', streamId: streamIds[1] }) + await expect(firstPending).resolves.toEqual({ done: true, value: undefined }) + await expect(secondPending).resolves.toEqual({ done: true, value: undefined }) + await client.close() + }) + }) + + it('keeps waiters across failed attempts and contains waiter cancellation', async () => { + await withFakeWebSocket('null', async () => { + FakeWebSocket.autoOpen = false + vi.useFakeTimers() + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const closedClient = new RemoteStreamMuxClient() + const closed = closedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[0]!.drop() + await vi.advanceTimersByTimeAsync(500) + + const replacement = FakeWebSocket.sockets[1]! + replacement.open() + await vi.advanceTimersByTimeAsync(0) + const { streamId } = JSON.parse(replacement.sent[0]!) as { streamId: string } + replacement.receive({ type: 'end', streamId }) + await expect(closed).resolves.toEqual({ done: true, value: undefined }) + await closedClient.close() + + const disposedClient = new RemoteStreamMuxClient() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + FakeWebSocket.sockets[2]!.fail() + await disposedClient.close() + await expect(disposed).rejects.toThrow('Remote stream client disposed') + + const abortedClient = new RemoteStreamMuxClient() + const abort = new AbortController() + const aborted = abortedClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + abort.abort('cancelled while connecting') + await expect(aborted).rejects.toBe('cancelled while connecting') + await abortedClient.close() + expect(FakeWebSocket.sockets[3]?.url).toBe('ws://dsh.internal/api/remote.mux') + } finally { + warn.mockRestore() + vi.useRealTimers() + } + }) + }) + + it('fails active streams on an invalid frame and ignores later frames', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + const stream = client.open('feed/follow', {}, new AbortController().signal)[Symbol.asyncIterator]() + const pending = stream.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const { streamId } = JSON.parse(socket.sent[0]!) as { streamId: string } + FakeWebSocket.dispatchClose = false + socket.receiveRaw(new Uint8Array([1, 2, 3])) + socket.receive({ type: 'item', streamId, value: 'too late' }) + socket.drop() + + await expect(pending).rejects.toMatchObject({ + name: 'RemoteStreamCarrierError', message: 'api gateway: invalid Remote stream frame', + }) + expect(socket.closedWith).toContainEqual({ code: 4002, reason: 'invalid Remote stream frame' }) + await client.close() + }) + }) + + it('completes a stream and drops a frame racing with cancellation', async () => { + await withFakeWebSocket('https://harness.example', async () => { + const client = new RemoteStreamMuxClient() + const completed = client.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]() + const completedPending = completed.next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + const socket = FakeWebSocket.sockets[0]! + const completedOpen = JSON.parse(socket.sent[0]!) as { streamId: string } + socket.receive({ type: 'end', streamId: completedOpen.streamId }) + await expect(completedPending).resolves.toEqual({ done: true, value: undefined }) + + const abort = new AbortController() + const cancelled = client.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(socket.sent).toHaveLength(2) }) + const cancelledOpen = JSON.parse(socket.sent[1]!) as { streamId: string } + const reason = new Error('fixture cancellation race') + abort.abort(reason) + socket.receive({ type: 'item', streamId: cancelledOpen.streamId, value: 'too late' }) + await expect(cancelled).rejects.toBe(reason) + await client.close() + }) + }) + + it('contains non-Error cancellation reasons and late socket close events', async () => { + await withFakeWebSocket('http://harness.example', async () => { + const cancelledClient = new RemoteStreamMuxClient() + const abort = new AbortController() + const cancelled = cancelledClient.open('feed/follow', {}, abort.signal)[Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[0]?.sent).toHaveLength(1) }) + abort.abort('caller cancelled') + await expect(cancelled).rejects.toThrow('caller cancelled') + await cancelledClient.close() + + FakeWebSocket.dispatchClose = false + const disposedClient = new RemoteStreamMuxClient() + const disposed = disposedClient.open('feed/follow', {}, new AbortController().signal) + [Symbol.asyncIterator]().next() + await vi.waitFor(() => { expect(FakeWebSocket.sockets[1]?.sent).toHaveLength(1) }) + const disposedSocket = FakeWebSocket.sockets[1]! + await disposedClient.close() + disposedSocket.receive({ type: 'end', streamId: 'stale' }) + disposedSocket.drop() + await expect(disposed).rejects.toThrow('Remote stream client disposed') + }) + }) +}) + +async function withFakeWebSocket( + origin: string | undefined, + run: () => Promise, +): Promise { + const originalWebSocket = globalThis.WebSocket + const locationDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'location') + ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket + if (origin === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', { configurable: true, value: { origin } }) + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + try { + await run() + } finally { + FakeWebSocket.sockets.length = 0 + FakeWebSocket.autoOpen = true + FakeWebSocket.dispatchClose = true + if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket + else globalThis.WebSocket = originalWebSocket + if (locationDescriptor === undefined) Reflect.deleteProperty(globalThis, 'location') + else Object.defineProperty(globalThis, 'location', locationDescriptor) + } +} diff --git a/packages/api/gateway/tests/gateway.host.spec.ts b/packages/api/gateway/tests/gateway.host.spec.ts index 8baa3d0b99..c0455bba10 100644 --- a/packages/api/gateway/tests/gateway.host.spec.ts +++ b/packages/api/gateway/tests/gateway.host.spec.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest' import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' +import type { HostConnectionHandle } from '@deepseek-ai/dsh-client-connection' import type { WebServer, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { bindTypertRemote, @@ -17,6 +18,7 @@ import { } from '@deepseek-ai/dsh-typert-protocol' import TypertRegistry, { type TypertContribution } from '@deepseek-ai/dsh-typert-registry' import TypertGatewayService, { TypertGatewayError } from '@deepseek-ai/dsh-api-gateway' +import { provideBrowserCredentials } from './browser-credentials.ts' interface FixtureAgent { readonly id: string @@ -104,7 +106,6 @@ type FakeRpcHandler = (endpoint: string, payload: unknown, signal: AbortSignal) class FakeConnectionService extends Service { channel: string | undefined - authority: string | undefined matches: ((endpoint: string) => boolean) | undefined handler: FakeRpcHandler | undefined @@ -119,22 +120,23 @@ class FakeConnectionService extends Service { channel: string, matches: (endpoint: string) => boolean, handler: FakeRpcHandler, - options: { readonly authority: string }, ) => owner.effect(() => { this.channel = channel - this.authority = options.authority this.matches = matches this.handler = handler return () => { this.channel = undefined - this.authority = undefined this.matches = undefined this.handler = undefined } }), } } + + requestRejection(): undefined { + return undefined + } } function fakeHttpServer(routes: WebRoute[]): Pick { @@ -168,6 +170,22 @@ async function serveRoute(route: WebRoute): Promise<{ readonly origin: string; c } } +/** Exchange a Connection launch token without mounting the frontend fallback. */ +function browserCookie(connection: HostConnectionHandle, origin: string): string { + const target = new URL(connection.authenticatedUrl(origin)) + let setCookie: string | undefined + connection.authorizeIndex({ + method: 'GET', + url: `${target.pathname}${target.search}`, + headers: { host: target.host }, + }, { + writeHead(_status, headers) { setCookie = headers?.['set-cookie'] }, + end() {}, + }) + if (setCookie === undefined) throw new Error('gateway fixture did not receive an authentication cookie') + return setCookie.split(';', 1)[0]! +} + class FirstSharedService extends Service { readonly typertRemote = bindTypertRemote(this, 'firstShared', { namespace: 'shared' }) @@ -735,7 +753,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toEqual([]) }) - it('distinguishes strict input and result validation failures', async () => { + it('validates strict input without decoding the business result', async () => { const { ctx, service } = await setup() registerStrict(ctx, [strictOnlyDescriptor()]) @@ -746,27 +764,23 @@ describe('TypertGatewayService', () => { }), 'input-invalid') service.nextResult = { title: 1 } - await expectCode(ctx.typertGateway.invoke({ + await expect(ctx.typertGateway.invoke({ namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, - }), 'result-invalid') + })).resolves.toEqual({ title: 1 }) }) - it('rejects non-JSON values after strict codec validation', async () => { + it('does not inspect non-JSON business results', async () => { const { ctx, service } = await setup() - const descriptor = strictOnlyDescriptor() - registerStrict(ctx, [{ - ...descriptor, - result: strictCodec('@fixture/gateway#UnknownResult', z.unknown()), - }]) + registerStrict(ctx, [strictOnlyDescriptor()]) service.nextResult = 1n - await expectCode(ctx.typertGateway.invoke({ + await expect(ctx.typertGateway.invoke({ namespace: 'goals', method: 'strictOnly', args: { request: { title: 'ship' } }, - }), 'result-invalid') + })).resolves.toBe(1n) }) it.each([ @@ -801,7 +815,7 @@ describe('TypertGatewayService', () => { expect(service.calls).toContain('passthrough') }) - it('rejects cyclic SRC input and non-JSON SRC results', async () => { + it('rejects cyclic SRC input without inspecting SRC results', async () => { const { ctx, service } = await setup() const cyclic: { self?: unknown } = {} cyclic.self = cyclic @@ -811,12 +825,13 @@ describe('TypertGatewayService', () => { args: { value: cyclic }, }), 'input-invalid') - service.nextResult = new Date(0) - await expectCode(ctx.typertGateway.invoke({ + const result = new Date(0) + service.nextResult = result + await expect(ctx.typertGateway.invoke({ namespace: 'goals', method: 'passthrough', args: { value: null }, - }), 'result-invalid') + })).resolves.toBe(result) }) it('accepts dense JSON and rejects decorated arrays and object properties', async () => { @@ -961,7 +976,7 @@ describe('TypertGatewayService', () => { await gatewayFiber await ctx.plugin(GoalService) const connection = rawConnection(ctx) - expect(connection).toMatchObject({ channel: '/api', authority: 'trusted-host' }) + expect(connection).toMatchObject({ channel: '/api' }) registerAgentLookup(ctx, { id: 'agent-1' }) registerStrict(ctx, [createDescriptor(), maybeDescriptor()]) @@ -1046,6 +1061,56 @@ describe('TypertGatewayService', () => { expect(connection.handler).toBeUndefined() }) + it('claims and validates in-process Remote event results for the active Client generation', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(FakeConnectionService) + await ctx.plugin(TypertGatewayService) + const connection = rawConnection(ctx) + const handler = connection.handler + if (handler === undefined) throw new Error('fixture Connection did not retain the /api interceptor') + expect(connection.matches?.('$events/result')).toBe(true) + + const result = { + args: { clientId: 'missing-client', eventId: 'missing', outcome: { kind: 'next' } }, + } + const inactive = await handler('$events/result', result, new AbortController().signal) + expect(inactive).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (inactive.ok) throw new Error('inactive Remote event result unexpectedly succeeded') + expect(inactive.error.message).toContain('identifies no active event stream') + + const unregister = ctx.typertGateway.registerRemoteEvents(signal => (async function* () { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + })()) + const carrier = new AbortController() + const events = rawGatewayEventHarness(ctx).openRemoteEvents({ args: {} }, carrier.signal) + const opening = await events.next() + expect(opening).toMatchObject({ done: false, value: { type: 'ready' } }) + if (opening.done) throw new Error('Remote event stream ended before ready') + const clientId: unknown = Reflect.get(opening.value as object, 'clientId') + if (typeof clientId !== 'string') throw new Error('Remote event stream omitted its Client id') + + for (const payload of [null, [], {}, { other: {} }]) { + const invalid = await handler('$events/result', payload, carrier.signal) + expect(invalid).toMatchObject({ ok: false, error: { code: 'internal' } }) + if (invalid.ok) throw new Error('invalid Remote event result payload unexpectedly succeeded') + expect(invalid.error.message).toContain('requires exactly one plain-object args field') + } + await expect(handler('$events/result', { + args: { clientId, eventId: 'missing', outcome: { kind: 'next' } }, + }, carrier.signal)).resolves.toEqual({ + ok: true, + value: undefined, + }) + + await events.return(undefined) + await unregister() + await ctx.fiber.dispose() + }) + it('preserves a lookup policy rejection through the Connection RPC result', async () => { const ctx = new Context() await ctx.plugin(TypertRegistry) @@ -1103,6 +1168,7 @@ describe('TypertGatewayService', () => { it('dispatches claimed invocations through /api and leaves unclaimed endpoints to its fallback', async () => { const ctx = new Context().extend({ fixtureScope: 'http-caller' }) const routes: WebRoute[] = [] + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes) as WebServer) const connectionFiber = ctx.plugin({ inject: [...connectionInject], apply: applyConnection }) await connectionFiber @@ -1116,11 +1182,12 @@ describe('TypertGatewayService', () => { let strictActive = true expect(routes).toHaveLength(1) const server = await serveRoute(routes[0]!) + const cookie = browserCookie(ctx.connection, server.origin) try { const response = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-http', @@ -1140,7 +1207,7 @@ describe('TypertGatewayService', () => { const invalid = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-invalid', @@ -1164,7 +1231,7 @@ describe('TypertGatewayService', () => { strictActive = false const withdrawn = await fetch(`${server.origin}/api/goals/create`, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', cookie }, body: JSON.stringify({ type: 'client-request', rpcId: 'rpc-withdrawn', @@ -1184,7 +1251,10 @@ describe('TypertGatewayService', () => { }) expect(JSON.stringify(withdrawnBody)).toContain('strict definition was withdrawn') - const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { method: 'POST' }) + const unclaimed = await fetch(`${server.origin}/api/legacy/list`, { + method: 'POST', + headers: { cookie }, + }) expect(unclaimed.status).toBe(404) } finally { await server.close() @@ -1228,6 +1298,17 @@ function rawConnection(ctx: Context): FakeConnectionService { return receiver[symbols.original] ?? receiver } +interface GatewayEventHarness { + openRemoteEvents(payload: unknown, signal: AbortSignal): AsyncGenerator +} + +function rawGatewayEventHarness(ctx: Context): GatewayEventHarness { + const receiver = ctx.get('typertGateway') as unknown as GatewayEventHarness & { + [symbols.original]?: GatewayEventHarness + } + return receiver[symbols.original] ?? receiver +} + function registerStrict(ctx: Context, descriptors: readonly InvocationDescriptor[]): () => Promise { return ctx.typert.register({ package: '@fixture/gateway', @@ -1256,6 +1337,7 @@ function contextProvider(context: Context) { return { wire: 'agentId', wireTypeSymbol: '@fixture/domain#AgentId', + identity: (candidate: Context) => candidate === context ? 'agent-1' : undefined, resolve: (id: string) => id === 'agent-1' ? context : undefined, } } diff --git a/packages/api/gateway/tests/journal-stream.client.spec.ts b/packages/api/gateway/tests/journal-stream.client.spec.ts new file mode 100644 index 0000000000..0d134f3d99 --- /dev/null +++ b/packages/api/gateway/tests/journal-stream.client.spec.ts @@ -0,0 +1,1074 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RemoteJournalStream, + RemoteStream, + RemoteStreamCarrierError, + type RemoteJournalChange, + type RemoteJournalFrame, + type RemoteStreamFactory, + type RemoteStreamItem, + type RemoteStreamOptions, +} from '../src/client/index.ts' + +interface Entry { + readonly seq: number + readonly lastSeq?: number +} + +interface Page { + readonly entries: readonly Entry[] + readonly hasMore: boolean + readonly marker: string +} + +interface PageRequest { + readonly before?: number + readonly limit?: number +} + +type JournalFrame = RemoteJournalFrame +type ScriptedFrame = JournalFrame + +interface Generation { + readonly frames: readonly ( + ScriptedFrame | Promise + )[] + readonly terminal?: Error + readonly hold?: boolean + readonly waitAfterFrames?: Promise + readonly afterFrame?: (index: number) => void +} + +type PageSource = Page | Promise | ((signal: AbortSignal) => Promise) + +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +const entries = (...seqs: number[]): Entry[] => seqs.map(seq => ({ seq })) + +const rangedEntry = (first: number, last: number): Entry => ({ seq: first, lastSeq: last }) + +const page = (marker: string, seqs: number[], hasMore = false): Page => ({ + entries: entries(...seqs), + hasMore, + marker, +}) + +const rangedPage = (marker: string, values: Entry[], hasMore = false): Page => ({ + entries: values, + hasMore, + marker, +}) + +const STREAM_FACTORY = { + $stream(options: RemoteStreamOptions): RemoteStream { + return new RemoteStream(AVAILABLE_CONNECTION, options) + }, +} + +class FixtureJournal extends RemoteJournalStream { + constructor( + private readonly generations: Generation[], + private readonly pages: PageSource[], + private readonly calls: string[], + private readonly pageRequests: PageRequest[], + private readonly pageCursors: number[], + private readonly followRequests: PageRequest[], + changes: RemoteJournalChange[], + failed: (error: unknown) => void, + factory: RemoteStreamFactory = STREAM_FACTORY, + ) { + super(factory, { + name: 'fixture journal', + emptyCursor: -1, + entries: value => value.entries, + hasMore: value => value.hasMore, + first: entry => entry.seq, + last: entry => entry.lastSeq ?? entry.seq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: (change) => { changes.push(change) }, + failed, + }) + } + + /** @inheritdoc */ + protected override async * follow( + request: PageRequest, + signal: AbortSignal, + ): AsyncIterable { + this.calls.push('follow') + this.followRequests.push(request) + const generation = this.generations.shift() + if (generation === undefined) throw new Error('no scripted journal generation') + for (const [index, frame] of generation.frames.entries()) { + yield await frame + generation.afterFrame?.(index) + } + await generation.waitAfterFrames + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } + + /** @inheritdoc */ + protected override readPage( + request: PageRequest, + through: number, + signal: AbortSignal, + ): Promise { + this.calls.push('page') + this.pageRequests.push(request) + this.pageCursors.push(through) + const value = this.pages.shift() + if (value === undefined) throw new Error('no scripted journal page') + return typeof value === 'function' ? value(signal) : Promise.resolve(value) + } + + /** @inheritdoc */ + protected override repairRequest(request: PageRequest): PageRequest { + return request.limit === undefined ? {} : { limit: request.limit } + } +} + +function journalFixture( + generations: Generation[], + pages: PageSource[], + factory: RemoteStreamFactory = STREAM_FACTORY, +): { + readonly journal: RemoteJournalStream + readonly changes: RemoteJournalChange[] + readonly failed: ReturnType + readonly calls: string[] + readonly pageRequests: PageRequest[] + readonly pageCursors: number[] + readonly followRequests: PageRequest[] +} { + const calls: string[] = [] + const pageRequests: PageRequest[] = [] + const pageCursors: number[] = [] + const followRequests: PageRequest[] = [] + const changes: RemoteJournalChange[] = [] + const failed = vi.fn() + const journal = new FixtureJournal( + generations, + pages, + calls, + pageRequests, + pageCursors, + followRequests, + changes, + failed, + factory, + ) + return { journal, changes, failed, calls, pageRequests, pageCursors, followRequests } +} + +function opened(cursor: number, value: Page): JournalFrame { + return { type: 'opened', cursor, page: value } +} + +function remoteItem( + generation: number, + value: ScriptedFrame, + signal: AbortSignal, +): RemoteStreamItem { + return { generation, value, signal, accept: vi.fn() } +} + +function controlledFactory( + next: () => Promise>>, +): RemoteStreamFactory { + const lifetime = new AbortController() + return { + $stream(): RemoteStream { + const iterator = { + next, + return: async () => ({ done: true as const, value: undefined }), + } + return { + signal: lifetime.signal, + restart: () => {}, + dispose: async () => { lifetime.abort() }, + [Symbol.asyncIterator]: () => iterator, + } as unknown as RemoteStream + }, + } +} + +describe('RemoteJournalStream', () => { + it('replaces from pages whose entries cover contiguous cursor ranges', async () => { + const snapshot = rangedPage( + 'ranged', + [rangedEntry(0, 2), rangedEntry(3, 5)], + true, + ) + const fixture = journalFixture( + [{ frames: [opened(5, snapshot)], hold: true }], + [], + ) + + await fixture.journal.open({}) + + expect(fixture.changes).toEqual([{ + type: 'replace', + page: snapshot, + entries: snapshot.entries, + hasMore: true, + }]) + await fixture.journal.dispose() + }) + + it('rejects an inverted cursor range', async () => { + const fixture = journalFixture( + [{ frames: [opened(2, rangedPage('inverted', [rangedEntry(3, 2)]))], hold: true }], + [], + ) + + await expect(fixture.journal.open({})).rejects.toThrow( + 'fixture journal entry has an inverted cursor range', + ) + expect(fixture.changes).toEqual([]) + }) + + it('opens from the follow snapshot, removes overlap, appends live entries, and prepends history', async () => { + const fixture = journalFixture( + [{ + frames: [ + opened(3, page('tail', [2, 3], true)), + { type: 'entry', entry: { seq: 3 } }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }], + [page('older', [0, 1])], + ) + + await fixture.journal.open({ limit: 2 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + await fixture.journal.prepend({ before: 2, limit: 2 }) + + expect(fixture.calls.slice(0, 2)).toEqual(['follow', 'page']) + expect(fixture.pageRequests).toEqual([{ before: 2, limit: 2 }]) + expect(fixture.pageCursors).toEqual([4]) + expect(fixture.changes).toEqual([ + { type: 'replace', page: page('tail', [2, 3], true), entries: entries(2, 3), hasMore: true }, + { type: 'append', entry: { seq: 4 } }, + { type: 'prepend', page: page('older', [0, 1]), entries: entries(0, 1), hasMore: false }, + ]) + await fixture.journal.dispose() + await fixture.journal.dispose() + }) + + it('exposes its shared cancellation signal', async () => { + const fixture = journalFixture( + [{ frames: [opened(-1, page('empty', []))], hold: true }], + [], + ) + + expect(fixture.journal.signal.aborted).toBe(false) + await fixture.journal.open({}) + await fixture.journal.dispose() + expect(fixture.journal.signal.aborted).toBe(true) + }) + + it('classifies normal endings before initial and resumed opening cursors', async () => { + const initial = journalFixture([{ frames: [] }], []) + await expect(initial.journal.open({})).rejects.toThrow( + 'fixture journal ended before its opening cursor', + ) + + const finish = Promise.withResolvers() + const resumed = journalFixture( + [ + { frames: [opened(0, page('initial', [0]))], waitAfterFrames: finish.promise }, + { frames: [] }, + ], + [], + ) + await resumed.journal.open({}) + finish.resolve(undefined) + await vi.waitFor(() => { expect(resumed.failed).toHaveBeenCalledOnce() }) + expect(resumed.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'resumed fixture journal ended before its opening cursor', + }) + await resumed.journal.dispose() + }) + + it('prepends into an empty window and accepts its first live entry', async () => { + const empty = journalFixture( + [{ frames: [opened(-1, page('empty', []))], hold: true }], + [page('older', [0]), page('oldest', [])], + ) + await empty.journal.open({}) + await empty.journal.prepend({}) + expect(empty.changes.at(-1)).toEqual({ + type: 'prepend', page: page('older', [0]), entries: entries(0), hasMore: false, + }) + await empty.journal.prepend({}) + expect(empty.changes.at(-1)).toEqual({ + type: 'prepend', page: page('oldest', []), entries: [], hasMore: false, + }) + await empty.journal.dispose() + + const live = Promise.withResolvers() + const followed = journalFixture( + [{ frames: [opened(-1, page('empty', [])), live.promise], hold: true }], + [], + ) + await followed.journal.open({}) + live.resolve({ type: 'entry', entry: { seq: 0 } }) + await vi.waitFor(() => { expect(followed.changes).toHaveLength(2) }) + expect(followed.changes.at(-1)).toEqual({ type: 'append', entry: { seq: 0 } }) + await followed.journal.dispose() + }) + + it('prepends at the first cursor and rejects a partially overlapping ranged entry', async () => { + const initial = rangedPage('initial', [rangedEntry(4, 6)], true) + const older = rangedPage('older', [rangedEntry(0, 3)]) + const fixture = journalFixture( + [{ frames: [opened(6, initial)], hold: true }], + [older], + ) + + await fixture.journal.open({}) + await fixture.journal.prepend({ before: 4 }) + + expect(fixture.pageCursors).toEqual([6]) + expect(fixture.changes.at(-1)).toEqual({ + type: 'prepend', page: older, entries: older.entries, hasMore: false, + }) + await fixture.journal.dispose() + + const overlap = rangedPage('overlap', [rangedEntry(0, 4)], true) + const overlapping = journalFixture( + [{ frames: [opened(6, initial)], hold: true }], + [overlap], + ) + await overlapping.journal.open({}) + + await expect(overlapping.journal.prepend({ before: 4 })).rejects.toThrow( + 'history page is discontinuous', + ) + expect(overlapping.changes.at(-1)).toEqual({ + type: 'prepend', page: overlap, entries: [], hasMore: false, + }) + await overlapping.journal.dispose() + }) + + it('deduplicates complete ranged entries and rejects partial live overlap', async () => { + const initial = rangedPage('initial', [rangedEntry(0, 2)]) + const fixture = journalFixture( + [{ + frames: [ + opened(2, initial), + { type: 'entry', entry: rangedEntry(0, 2) }, + { type: 'entry', entry: rangedEntry(3, 5) }, + { type: 'entry', entry: rangedEntry(5, 7) }, + ], + hold: true, + }], + [], + ) + + await fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + + expect(fixture.changes).toHaveLength(2) + expect(fixture.changes.at(-1)).toEqual({ + type: 'append', entry: rangedEntry(3, 5), + }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'fixture journal emitted a partially overlapping entry', + }) + await fixture.journal.dispose() + }) + + it('repairs a replacement generation through one tail page and drops replay overlap', async () => { + const lost = new RemoteStreamCarrierError('carrier lost') + const fixture = journalFixture( + [ + { + frames: [ + opened(1, page('initial', [0, 1])), + { type: 'entry', entry: { seq: 2 } }, + ], + terminal: lost, + }, + { + frames: [ + opened(4, page('replacement', [0, 1, 2, 3, 4])), + { type: 'entry', entry: { seq: 3 } }, + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }, + ], + [], + ) + + await fixture.journal.open({ limit: 5 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(3) }) + + expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) + expect(fixture.changes[2]).toMatchObject({ + type: 'replace', page: { marker: 'replacement' }, entries: entries(0, 1, 2, 3, 4), + }) + expect(fixture.followRequests).toEqual([{ limit: 5 }, { limit: 5 }]) + expect(fixture.pageCursors).toEqual([]) + expect(fixture.failed).not.toHaveBeenCalled() + await fixture.journal.dispose() + }) + + it('restarts a page aborted with its carrier generation', async () => { + const fixture = journalFixture( + [ + { + frames: [ + opened(1, page('initial', [0, 1])), + { type: 'entry', entry: { seq: 3 } }, + ], + terminal: new RemoteStreamCarrierError('carrier lost during page'), + }, + { + frames: [opened(3, page('replacement', [0, 1, 2, 3]))], + hold: true, + }, + ], + [ + signal => new Promise((_resolve, reject) => { + const aborted = (): void => { reject(new Error('page aborted')) } + signal.addEventListener('abort', aborted, { once: true }) + if (signal.aborted) aborted() + }), + ], + ) + + await fixture.journal.open({ limit: 3 }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.changes).toEqual([ + { + type: 'replace', + page: page('initial', [0, 1]), + entries: entries(0, 1), + hasMore: false, + }, + { + type: 'replace', + page: page('replacement', [0, 1, 2, 3]), + entries: entries(0, 1, 2, 3), + hasMore: false, + }, + ]) + expect(fixture.pageCursors).toEqual([3]) + expect(fixture.followRequests).toEqual([{ limit: 3 }, { limit: 3 }]) + expect(fixture.failed).not.toHaveBeenCalled() + await fixture.journal.dispose() + }) + + it('repairs a live gap before publishing another change', async () => { + const fixture = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + { type: 'entry', entry: { seq: 4 } }, + ], + hold: true, + }], + [page('repair', [0, 1, 2, 3, 4])], + ) + + await fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.changes.map(change => change.type)).toEqual(['replace', 'replace']) + expect(fixture.changes[1]).toMatchObject({ page: { marker: 'repair' } }) + expect(fixture.pageCursors).toEqual([4]) + await fixture.journal.dispose() + }) + + it('reports a page failure during live-gap repair', async () => { + const fixture = journalFixture( + [{ + frames: [ + opened(0, page('initial', [0])), + { type: 'entry', entry: { seq: 2 } }, + ], + hold: true, + }], + [() => Promise.reject(new Error('repair page failed'))], + ) + + await fixture.journal.open({}) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ message: 'repair page failed' }) + expect(fixture.changes).toHaveLength(1) + await fixture.journal.dispose() + }) + + it('replaces a superseded live-gap repair with the next generation', async () => { + const gap = Promise.withResolvers() + const fixture = journalFixture( + [ + { + frames: [opened(1, page('initial', [0, 1])), gap.promise], + terminal: new RemoteStreamCarrierError('generation lost'), + }, + { frames: [opened(4, page('replacement', [0, 1, 2, 3, 4]))], hold: true }, + ], + [ + () => new Promise(() => {}), + ], + ) + + await fixture.journal.open({ limit: 5 }) + gap.resolve({ type: 'entry', entry: { seq: 4 } }) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + expect(fixture.changes.at(-1)).toMatchObject({ + type: 'replace', page: { marker: 'replacement' }, entries: entries(0, 1, 2, 3, 4), + }) + await fixture.journal.dispose() + }) + + it('replaces a superseded second repair page with the next generation', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const firstRepair = Promise.withResolvers() + const finish = Promise.withResolvers() + const fixture = journalFixture( + [ + { + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], + waitAfterFrames: finish.promise, + terminal: new RemoteStreamCarrierError('generation lost'), + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, + }, + { frames: [opened(5, page('replacement', [0, 1, 2, 3, 4, 5]))], hold: true }, + ], + [ + firstRepair.promise, + signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true }) + }), + ], + ) + + await fixture.journal.open({}) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) + await secondConsumed.promise + firstRepair.resolve(page('first-repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3, 5]) }) + finish.resolve(undefined) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.pageCursors).toEqual([3, 5]) + expect(fixture.changes).toEqual([ + { + type: 'replace', + page: page('initial', [0, 1]), + entries: entries(0, 1), + hasMore: false, + }, + { + type: 'replace', + page: page('replacement', [0, 1, 2, 3, 4, 5]), + entries: entries(0, 1, 2, 3, 4, 5), + hasMore: false, + }, + ]) + await fixture.journal.dispose() + }) + + it('rereads the tail when queued entries advance beyond the first repair page', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const firstRepair = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], + hold: true, + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, + }], + [firstRepair.promise, page('repair', [0, 1, 2, 3, 4, 5])], + ) + + await fixture.journal.open({ limit: 4 }) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) + await secondConsumed.promise + firstRepair.resolve(page('first-repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.pageCursors).toEqual([3, 5]) + expect(fixture.changes.at(-1)).toEqual({ + type: 'replace', + page: page('repair', [0, 1, 2, 3, 4, 5]), + entries: entries(0, 1, 2, 3, 4, 5), + hasMore: false, + }) + await fixture.journal.dispose() + }) + + it('merges contiguous entries that arrive while a replacement page is loading', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const repair = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], + hold: true, + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, + }], + [repair.promise], + ) + + await fixture.journal.open({}) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 4 } }) + await secondConsumed.promise + repair.resolve(page('repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + + expect(fixture.changes.at(-1)).toEqual({ + type: 'replace', + page: page('repair', [0, 1, 2, 3]), + entries: entries(0, 1, 2, 3, 4), + hasMore: false, + }) + await fixture.journal.dispose() + }) + + it('rejects a partially overlapping ranged entry queued during repair', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const repair = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + ], + hold: true, + afterFrame: (index) => { if (index === 2) secondConsumed.resolve(undefined) }, + }], + [repair.promise], + ) + + await fixture.journal.open({}) + firstLive.resolve({ type: 'entry', entry: rangedEntry(3, 5) }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([5]) }) + secondLive.resolve({ type: 'entry', entry: rangedEntry(5, 7) }) + await secondConsumed.promise + repair.resolve(rangedPage('repair', [rangedEntry(0, 2), rangedEntry(3, 5)])) + + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.changes).toHaveLength(1) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'fixture journal replacement contains a partially overlapping entry', + }) + await fixture.journal.dispose() + }) + + it('rejects when queued entries advance beyond the second repair page', async () => { + const firstLive = Promise.withResolvers() + const secondLive = Promise.withResolvers() + const thirdLive = Promise.withResolvers() + const secondConsumed = Promise.withResolvers() + const thirdConsumed = Promise.withResolvers() + const firstRepair = Promise.withResolvers() + const secondRepair = Promise.withResolvers() + const fixture = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + firstLive.promise, + secondLive.promise, + thirdLive.promise, + ], + hold: true, + afterFrame: (index) => { + if (index === 2) secondConsumed.resolve(undefined) + if (index === 3) thirdConsumed.resolve(undefined) + }, + }], + [firstRepair.promise, secondRepair.promise], + ) + + await fixture.journal.open({}) + firstLive.resolve({ type: 'entry', entry: { seq: 3 } }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3]) }) + secondLive.resolve({ type: 'entry', entry: { seq: 5 } }) + await secondConsumed.promise + firstRepair.resolve(page('first-repair', [0, 1, 2, 3])) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([3, 5]) }) + thirdLive.resolve({ type: 'entry', entry: { seq: 7 } }) + await thirdConsumed.promise + secondRepair.resolve(page('second-repair', [0, 1, 2, 3, 4, 5])) + + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'fixture journal page did not reach its opening cursor', + }) + await fixture.journal.dispose() + }) + + it('reports a resumed generation that emits an entry before its cursor', async () => { + const finish = Promise.withResolvers() + const fixture = journalFixture( + [ + { + frames: [opened(0, page('initial', [0]))], + waitAfterFrames: finish.promise, + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [{ type: 'entry', entry: { seq: 1 } }] }, + ], + [], + ) + + await fixture.journal.open({}) + finish.resolve(undefined) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'resumed fixture journal emitted an entry before its opening cursor', + }) + await fixture.journal.dispose() + }) + + it('reports a duplicate opening cursor after the initial page is published', async () => { + const duplicate = Promise.withResolvers() + const fixture = journalFixture( + [{ frames: [opened(0, page('initial', [0])), duplicate.promise], hold: true }], + [], + ) + + await fixture.journal.open({}) + duplicate.resolve(opened(0, page('duplicate', [0]))) + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + expect(fixture.failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'fixture journal emitted more than one opening cursor', + }) + await fixture.journal.dispose() + }) + + it('reports a follow failure after publishing its opening snapshot', async () => { + const failedFollow = journalFixture( + [{ frames: [opened(0, page('initial', [0]))], terminal: new Error('follow failed') }], + [], + ) + await failedFollow.journal.open({}) + await vi.waitFor(() => { expect(failedFollow.failed).toHaveBeenCalledOnce() }) + expect(failedFollow.failed.mock.calls[0]?.[0]).toMatchObject({ message: 'follow failed' }) + expect(failedFollow.changes).toHaveLength(1) + await failedFollow.journal.dispose() + }) + + it('rejects an iterator that ends before its opening cursor', async () => { + const factory = controlledFactory(() => Promise.resolve({ done: true, value: undefined })) + const fixture = journalFixture([], [], factory) + + await expect(fixture.journal.open({})).rejects.toThrow( + 'ended before its opening cursor', + ) + }) + + it('suppresses a consumer failure after disposal begins', async () => { + const generation = new AbortController() + const next = Promise.withResolvers>>() + const results = [ + Promise.resolve>>({ + done: false, + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), + }), + next.promise, + ] + const fixture = journalFixture( + [], + [], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + const closing = fixture.journal.dispose() + next.resolve({ + done: false, + value: remoteItem(1, opened(0, page('duplicate', [0])), generation.signal), + }) + await closing + expect(fixture.failed).not.toHaveBeenCalled() + }) + + it.each([ + { name: 'ends', final: { done: true as const, value: undefined }, message: 'ended while replacing' }, + { + name: 'emits another opening cursor', + final: undefined, + message: 'more than one opening cursor', + }, + ])('reports when an aborted repair generation $name', async ({ final, message }) => { + const generation = new AbortController() + const gap = Promise.withResolvers>>() + const replacement = Promise.withResolvers>>() + const results = [ + Promise.resolve>>({ + done: false, + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), + }), + gap.promise, + replacement.promise, + ] + const fixture = journalFixture( + [], + [signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true }) + })], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + gap.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal), + }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) }) + generation.abort() + if (final === undefined) { + replacement.resolve({ + done: false, + value: remoteItem(1, opened(2, page('duplicate', [0, 1, 2])), generation.signal), + }) + } else { + replacement.resolve(final) + } + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + const failure: unknown = fixture.failed.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('journal failure was not an Error') + expect(failure.message).toContain(message) + await fixture.journal.dispose() + }) + + it('discards old-generation entries while waiting for the replacement opening', async () => { + const generation = new AbortController() + const gap = Promise.withResolvers>>() + const stale = Promise.withResolvers>>() + const replacement = Promise.withResolvers>>() + const results = [ + Promise.resolve>>({ + done: false, + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), + }), + gap.promise, + stale.promise, + replacement.promise, + ] + const fixture = journalFixture( + [], + [signal => new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { reject(new Error('page aborted')) }, { once: true }) + })], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + gap.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal), + }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) }) + generation.abort() + stale.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 1 } }, generation.signal), + }) + replacement.resolve({ + done: false, + value: remoteItem(2, opened(2, page('replacement', [0, 1, 2])), new AbortController().signal), + }) + + await vi.waitFor(() => { expect(fixture.changes).toHaveLength(2) }) + expect(fixture.changes.at(-1)).toMatchObject({ page: { marker: 'replacement' } }) + await fixture.journal.dispose() + }) + + it.each([ + { + name: 'rejects', + settle: ( + _resolve: (value: IteratorResult>) => void, + reject: (reason?: unknown) => void, + ) => { reject(new Error('replacement follow failed')) }, + message: 'replacement follow failed', + }, + { + name: 'ends', + settle: (resolve: (value: IteratorResult>) => void) => { + resolve({ done: true, value: undefined }) + }, + message: 'ended while reading its replacement page', + }, + { + name: 'opens twice', + settle: (resolve: (value: IteratorResult>) => void) => { + resolve({ + done: false, + value: remoteItem(1, opened(2, page('duplicate', [0, 1, 2])), new AbortController().signal), + }) + }, + message: 'more than one opening cursor', + }, + ])('reports when a follow $name during live-gap repair', async ({ settle, message }) => { + const generation = new AbortController() + const gap = Promise.withResolvers>>() + const next = Promise.withResolvers>>() + const results = [ + Promise.resolve>>({ + done: false, + value: remoteItem(1, opened(0, page('initial', [0])), generation.signal), + }), + gap.promise, + next.promise, + ] + const fixture = journalFixture( + [], + [() => new Promise(() => {})], + controlledFactory(() => results.shift() ?? Promise.resolve({ done: true, value: undefined })), + ) + + await fixture.journal.open({}) + gap.resolve({ + done: false, + value: remoteItem(1, { type: 'entry', entry: { seq: 2 } }, generation.signal), + }) + await vi.waitFor(() => { expect(fixture.pageCursors).toEqual([2]) }) + settle(next.resolve, next.reject) + + await vi.waitFor(() => { expect(fixture.failed).toHaveBeenCalledOnce() }) + const failure: unknown = fixture.failed.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('journal failure was not an Error') + expect(failure.message).toContain(message) + await fixture.journal.dispose() + }) + + it('rejects malformed opening and page sequences', async () => { + const beforeOpening = journalFixture( + [{ frames: [{ type: 'entry', entry: { seq: 0 } }] }], + [], + ) + await expect(beforeOpening.journal.open({})).rejects.toThrow('entry before its opening cursor') + + const discontinuousPage = journalFixture( + [{ frames: [opened(3, page('bad', [0, 2, 3]))], hold: true }], + [], + ) + await expect(discontinuousPage.journal.open({})).rejects.toThrow('page contains discontinuous entries') + + const shortPage = journalFixture( + [{ frames: [opened(3, page('short', [0, 1]))], hold: true }], + [], + ) + await expect(shortPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor') + + const longPage = journalFixture( + [{ frames: [opened(1, page('long', [0, 1, 2]))], hold: true }], + [], + ) + await expect(longPage.journal.open({})).rejects.toThrow('page did not end at its requested cursor') + }) + + it('reports duplicate and regressed generation cursors as terminal failures', async () => { + const duplicate = journalFixture( + [{ + frames: [ + opened(1, page('initial', [0, 1])), + opened(1, page('duplicate', [0, 1])), + ], + }], + [], + ) + await duplicate.journal.open({}) + await vi.waitFor(() => { expect(duplicate.failed).toHaveBeenCalledOnce() }) + const duplicateFailure: unknown = duplicate.failed.mock.calls[0]?.[0] + expect(duplicateFailure).toBeInstanceOf(Error) + if (!(duplicateFailure instanceof Error)) throw new Error('expected duplicate-cursor failure') + expect(duplicateFailure.message).toContain('more than one opening cursor') + + const regressed = journalFixture( + [ + { + frames: [opened(1, page('initial', [0, 1])), { type: 'entry', entry: { seq: 2 } }], + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [opened(1, page('regressed', [0, 1]))] }, + ], + [], + ) + await regressed.journal.open({}) + await vi.waitFor(() => { expect(regressed.failed).toHaveBeenCalledOnce() }) + const regressedFailure: unknown = regressed.failed.mock.calls[0]?.[0] + expect(regressedFailure).toBeInstanceOf(Error) + if (!(regressedFailure instanceof Error)) throw new Error('expected regressed-cursor failure') + expect(regressedFailure.message).toContain('behind the last applied entry') + }) + + it('rejects a discontinuous older page after publishing the fail-soft pagination state', async () => { + const fixture = journalFixture( + [{ frames: [opened(4, page('initial', [3, 4], true))], hold: true }], + [page('older', [0, 1], true)], + ) + await fixture.journal.open({}) + + await expect(fixture.journal.prepend({ before: 3 })).rejects.toThrow('history page is discontinuous') + expect(fixture.changes.at(-1)).toEqual({ + type: 'prepend', page: page('older', [0, 1], true), entries: [], hasMore: false, + }) + await fixture.journal.dispose() + }) + + it('guards lifecycle operations before and after open', async () => { + const fixture = journalFixture( + [{ frames: [opened(-1, page('empty', []))], hold: true }], + [], + ) + + await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') + await fixture.journal.open({}) + await expect(fixture.journal.open({})).rejects.toThrow('already opened') + fixture.journal.restart() + await fixture.journal.dispose() + await expect(fixture.journal.prepend({})).rejects.toThrow('is not open') + }) +}) diff --git a/packages/api/gateway/tests/remote-event-protocol.host.spec.ts b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts new file mode 100644 index 0000000000..09259ea43f --- /dev/null +++ b/packages/api/gateway/tests/remote-event-protocol.host.spec.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' +import { + isRemoteJsonValue, + parseRemoteEventResult, + parseRemoteStreamClientMessage, + projectRemoteEventRequest, + projectRemoteEventRejection, + restoreRemoteEventRejection, +} from '../src/stream-protocol.ts' + +describe('Remote Event result protocol', () => { + it('accepts delegation, values, and structured rejections', () => { + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, + })).toEqual({ clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' } }) + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' }, + })).toEqual({ clientId: 'client-1', eventId: 'event-2', outcome: { kind: 'result' } }) + expect(parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } }, + })).toEqual({ + clientId: 'client-1', eventId: 'event-3', outcome: { kind: 'result', value: { accepted: true } }, + }) + expect(parseRemoteEventResult({ + clientId: 'client-1', + eventId: 'event-minimal', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } }, + })).toEqual({ + clientId: 'client-1', + eventId: 'event-minimal', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'offline' } }, + }) + expect(parseRemoteEventResult({ + clientId: 'client-1', + eventId: 'event-4', + outcome: { + kind: 'rejected', + error: { + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }, + }, + })).toEqual({ + clientId: 'client-1', + eventId: 'event-4', + outcome: { + kind: 'rejected', + error: { + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }, + }, + }) + }) + + it.each([ + null, + [], + {}, + { clientId: '', eventId: 'event-1', outcome: { kind: 'next' } }, + { clientId: 'client-1', eventId: '', outcome: { kind: 'next' } }, + { clientId: 'client-1', eventId: 'event-1', outcome: null }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next' }, extra: true }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'next', value: null } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', extra: true } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'result', value: undefined } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'unknown' } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: null } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: '', message: 'bad' } } }, + { clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error: { name: 'Error', message: 1 } } }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', code: 1 } }, + }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', details: 1n } }, + }, + { + clientId: 'client-1', + eventId: 'event-1', + outcome: { kind: 'rejected', error: { name: 'Error', message: 'bad', extra: true } }, + }, + ])('rejects an invalid result frame: %#', (value) => { + expect(() => parseRemoteEventResult(value)).toThrow('api gateway: invalid Remote event') + }) + + it('rejects symbol properties in rejection records', () => { + const error = { name: 'Error', message: 'bad', [Symbol('hidden')]: true } + expect(() => parseRemoteEventResult({ + clientId: 'client-1', eventId: 'event-1', outcome: { kind: 'rejected', error }, + })).toThrow('api gateway: invalid Remote event rejection') + }) +}) + +describe('Remote Event request projection', () => { + it('removes only the direct Agent and signal fields', () => { + const agent = { kind: 'agent' } + const abort = new AbortController() + const nested = { agent, signal: 'payload' } + const projected = projectRemoteEventRequest({ + agent, + signal: abort.signal, + prompt: 'approve?', + nested, + }, agent) + + expect(projected).toEqual({ + request: { prompt: 'approve?', nested }, + signal: abort.signal, + }) + expect(Object.getPrototypeOf(projected.request)).toBeNull() + }) + + it('accepts a null-prototype request and an omitted signal', () => { + const agent = { kind: 'agent' } + const request = Object.assign(Object.create(null) as Record, { + agent, + accepted: true, + }) + expect(projectRemoteEventRequest(request, agent)).toEqual({ + request: { accepted: true }, + }) + }) + + it('requires the scoped Agent as a direct own field', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest(null, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest({}, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest({ agent: {} }, agent)) + .toThrow('must carry its scoped Agent directly') + expect(() => projectRemoteEventRequest(Object.create({ agent }), agent)) + .toThrow('must carry its scoped Agent directly') + }) + + it('rejects an invalid direct signal', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, signal: 'abort' }, agent)) + .toThrow('request signal must be an AbortSignal') + }) + + it('rejects non-JSON payload fields', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, value: 1n }, agent)) + .toThrow('request is not lossless JSON data') + + const cycle: Record = {} + cycle.self = cycle + expect(() => projectRemoteEventRequest({ agent, cycle }, agent)) + .toThrow('request is not lossless JSON data') + }) + + it('rejects symbol and non-enumerable payload fields', () => { + const agent = { kind: 'agent' } + expect(() => projectRemoteEventRequest({ agent, [Symbol('hidden')]: true }, agent)) + .toThrow('request has a non-JSON property') + + const hidden = { agent } + Object.defineProperty(hidden, 'value', { value: true }) + expect(() => projectRemoteEventRequest(hidden, agent)) + .toThrow('request has a non-JSON property') + }) +}) + +describe('Remote Event rejection projection', () => { + it('preserves stable error fields in both directions', () => { + const reason = Object.assign(new Error('declined'), { + name: 'ApprovalError', + code: 'DECLINED', + details: { retryable: false }, + }) + expect(projectRemoteEventRejection(reason)).toEqual({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) + + const restored = restoreRemoteEventRejection({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) as Error & { code?: string; details?: unknown } + expect(restored).toMatchObject({ + name: 'ApprovalError', + message: 'declined', + code: 'DECLINED', + details: { retryable: false }, + }) + }) + + it('normalizes arbitrary reasons and omits non-JSON optional fields', () => { + expect(projectRemoteEventRejection('offline')).toEqual({ + name: 'Error', message: 'offline', + }) + expect(projectRemoteEventRejection(undefined)).toEqual({ + name: 'Error', message: 'undefined', + }) + expect(projectRemoteEventRejection({ + name: 1, message: 2, code: 3, details: 1n, + })).toEqual({ + name: 'Error', message: '[object Object]', + }) + + const restored = restoreRemoteEventRejection({ name: 'Error', message: 'offline' }) + expect(restored).toMatchObject({ name: 'Error', message: 'offline' }) + expect(restored).not.toHaveProperty('code') + expect(restored).not.toHaveProperty('details') + }) +}) + +describe('Remote Event JSON values', () => { + it('accepts lossless JSON values, null-prototype objects, and repeated references', () => { + const shared = { value: 1 } + const nullPrototype = Object.assign(Object.create(null) as Record, { + enabled: true, + }) + expect(isRemoteJsonValue({ + null: null, + string: 'value', + boolean: true, + number: 1.5, + array: [shared, shared], + nullPrototype, + })).toBe(true) + }) + + it.each([ + undefined, + 1n, + Symbol('value'), + () => undefined, + NaN, + Number.POSITIVE_INFINITY, + -0, + ])('rejects a non-lossless scalar: %s', (value) => { + expect(isRemoteJsonValue(value)).toBe(false) + }) + + it('rejects cycles and non-plain arrays and objects', () => { + const cycle: Record = {} + cycle.self = cycle + expect(isRemoteJsonValue(cycle)).toBe(false) + + class Fixture { + value = 1 + } + expect(isRemoteJsonValue(new Fixture())).toBe(false) + + const customArray = [1] + Object.setPrototypeOf(customArray, null) + expect(isRemoteJsonValue(customArray)).toBe(false) + expect(isRemoteJsonValue(Object.assign([1], { extra: true }))).toBe(false) + + const sparse = new Array(2) + sparse[1] = 'value' + expect(isRemoteJsonValue(sparse)).toBe(false) + const disguisedSparse = Object.assign(new Array(2), { extra: true }) + disguisedSparse[1] = 'value' + expect(isRemoteJsonValue(disguisedSparse)).toBe(false) + expect(isRemoteJsonValue([undefined])).toBe(false) + + const symbolic = { [Symbol('value')]: true } + expect(isRemoteJsonValue(symbolic)).toBe(false) + const hidden = {} + Object.defineProperty(hidden, 'value', { value: true }) + expect(isRemoteJsonValue(hidden)).toBe(false) + expect(isRemoteJsonValue({ nested: undefined })).toBe(false) + }) +}) + +describe('Remote stream client protocol', () => { + it('rejects the removed logical-stream input message', () => { + expect(() => parseRemoteStreamClientMessage(JSON.stringify({ + type: 'input', streamId: 'stream-1', value: { answer: true }, + }))).toThrow('api gateway: invalid Remote stream client message') + }) +}) diff --git a/packages/api/gateway/tests/stream-protocol.host.spec.ts b/packages/api/gateway/tests/stream-protocol.host.spec.ts new file mode 100644 index 0000000000..f353dce901 --- /dev/null +++ b/packages/api/gateway/tests/stream-protocol.host.spec.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest' +import { + parseRemoteStreamClientMessage, + parseRemoteStreamServerMessage, +} from '../src/stream-protocol.ts' + +describe('Remote stream wire protocol', () => { + it('accepts every client message variant', () => { + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 }, + }))).toEqual({ + type: 'open', streamId: 'stream-1', endpoint: 'feed/follow', payload: { cursor: 1 }, + }) + expect(parseRemoteStreamClientMessage(JSON.stringify({ + type: 'cancel', streamId: 'stream-1', + }))).toEqual({ type: 'cancel', streamId: 'stream-1' }) + }) + + it.each([ + { type: 'open', streamId: '', endpoint: 'feed/follow', payload: {} }, + { type: 'open', streamId: 'stream-1', endpoint: '', payload: {} }, + { type: 'open', streamId: 'stream-1', endpoint: 'feed/follow' }, + { type: 'cancel', streamId: 'stream-1', extra: true }, + { type: 'unknown', streamId: 'stream-1' }, + ])('rejects an invalid client message: %j', (message) => { + expect(() => parseRemoteStreamClientMessage(JSON.stringify(message))) + .toThrow('api gateway: invalid Remote stream client message') + }) + + it('accepts every server message variant', () => { + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', value: null, + }))).toEqual({ type: 'item', streamId: 'stream-1', value: null }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'item', streamId: 'stream-1', + }))).toEqual({ type: 'item', streamId: 'stream-1' }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'error', + streamId: 'stream-1', + error: { code: 'offline', message: 'connection lost', details: {} }, + }))).toEqual({ + type: 'error', + streamId: 'stream-1', + error: { code: 'offline', message: 'connection lost', details: {} }, + }) + expect(parseRemoteStreamServerMessage(JSON.stringify({ + type: 'end', streamId: 'stream-1', + }))).toEqual({ type: 'end', streamId: 'stream-1' }) + }) + + it.each([ + { type: 'item', streamId: '', value: 'item' }, + { type: 'item', streamId: 'stream-1', extra: true }, + { type: 'end', streamId: 'stream-1', extra: true }, + { type: 'error', streamId: 'stream-1', error: [] }, + { type: 'error', streamId: 'stream-1', error: { code: 1, message: 'failure', details: {} } }, + { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 1, details: {} } }, + { type: 'error', streamId: 'stream-1', error: { code: 'failed', message: 'failure', details: [] } }, + { type: 'unknown', streamId: 'stream-1' }, + ])('rejects an invalid server message: %j', (message) => { + expect(() => parseRemoteStreamServerMessage(JSON.stringify(message))) + .toThrow('api gateway: invalid Remote stream server message') + }) + + it.each(['not json', 'null', '[]', '1'])('rejects a non-message payload: %s', (text) => { + expect(() => parseRemoteStreamServerMessage(text)).toThrow('api gateway: Remote stream message') + }) +}) diff --git a/packages/api/gateway/tests/stream-server.host.spec.ts b/packages/api/gateway/tests/stream-server.host.spec.ts new file mode 100644 index 0000000000..9746943b63 --- /dev/null +++ b/packages/api/gateway/tests/stream-server.host.spec.ts @@ -0,0 +1,246 @@ +import { once } from 'node:events' +import { createServer, type Server } from 'node:http' +import { afterEach, describe, expect, it, vi } from 'vitest' +import WebSocket from 'ws' +import { + RemoteStreamMuxServer, + type RemoteStreamFailureMapper, + type RemoteStreamOpener, +} from '../src/stream-server.ts' + +interface RunningMux { + readonly http: Server + readonly mux: RemoteStreamMuxServer + readonly url: string +} + +const running = new Set() + +afterEach(async () => { + await Promise.all([...running].map(async (entry) => { + running.delete(entry) + await entry.mux.close().catch(() => undefined) + await closeHttp(entry.http) + })) +}) + +describe('Remote stream mux server carrier lifecycle', () => { + it('rejects binary, malformed, and duplicate logical-stream messages', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + + const binary = await connect(entry.url) + const binaryClosed = once(binary, 'close') + binary.send(Buffer.from('{}')) + const binaryEvent = await binaryClosed + expect(binaryEvent[0]).toBe(1003) + + const malformed = await connect(entry.url) + const malformedClosed = once(malformed, 'close') + malformed.send('not json') + const malformedEvent = await malformedClosed + expect(malformedEvent[0]).toBe(1008) + expect(String(malformedEvent[1])).toBe('invalid Remote stream request') + + const duplicate = await connect(entry.url) + const longId = 'same'.repeat(100) + duplicate.send(openFrame(longId)) + duplicate.send(openFrame(longId)) + const duplicateEvent = await once(duplicate, 'close') + expect(duplicateEvent[0]).toBe(1008) + expect(String(duplicateEvent[1])).toBe('invalid Remote stream request') + + const noInput = await connect(entry.url) + noInput.send(openFrame('no-input')) + noInput.send(JSON.stringify({ type: 'input', streamId: 'no-input', value: 'unexpected' })) + const noInputEvent = await once(noInput, 'close') + expect(noInputEvent[0]).toBe(1008) + expect(String(noInputEvent[1])).toBe('invalid Remote stream request') + }) + + it('accepts all ws text representations and terminates a carrier error', async () => { + const entry = await startMux(async (_endpoint, _payload, signal) => waitForAbort(signal)) + const client = await connect(entry.url) + const serverSocket = acceptedSocket(entry.mux) + const cancel = JSON.stringify({ type: 'cancel', streamId: 'absent' }) + + serverSocket.emit('message', [Buffer.from(cancel)], false) + serverSocket.emit('message', Uint8Array.from(Buffer.from(cancel)).buffer, false) + + const closed = once(client, 'close') + serverSocket.emit('error', new Error('fixture carrier failure')) + await closed + }) + + it('does not send an end frame after clean source cancellation', async () => { + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async (_endpoint, _payload, signal) => { + opened() + return cleanlyCancelled(signal, returned) + }) + const client = await connect(entry.url) + const frames: unknown[] = [] + client.on('message', (data) => { + if (!Buffer.isBuffer(data)) throw new TypeError('fixture expected a Buffer frame') + frames.push(JSON.parse(data.toString('utf8')) as unknown) + }) + client.send(openFrame('cancelled')) + await didOpen + client.send(JSON.stringify({ type: 'cancel', streamId: 'cancelled' })) + await didReturn + await new Promise((resolve) => { setImmediate(resolve) }) + expect(frames).toEqual([]) + client.close() + await once(client, 'close') + }) + + it('closes the carrier when ws reports an item write failure', async () => { + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + const entry = await startMux(async () => delayedItem(released, opened)) + const client = await connect(entry.url) + client.send(openFrame('write-failure')) + await didOpen + const serverSocket = acceptedSocket(entry.mux) + const mutable = serverSocket as unknown as { + send(data: unknown, callback: (error?: Error) => void): void + } + mutable.send = (_data, callback): void => { + callback(new Error('fixture ws write failure')) + } + + const closed = once(client, 'close') + release() + const closeEvent = await closed + expect(closeEvent[0]).toBe(1011) + expect(String(closeEvent[1])).toBe('Remote stream failure could not be delivered') + }) + + it('contains an item produced after its socket closes', async () => { + let release!: () => void + const released = new Promise((resolve) => { release = resolve }) + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async () => delayedItem(released, opened, returned)) + const client = await connect(entry.url) + client.send(openFrame('late-item')) + await didOpen + const serverSocket = acceptedSocket(entry.mux) + client.close() + await once(client, 'close') + await vi.waitFor(() => { expect(serverSocket.readyState).toBe(WebSocket.CLOSED) }) + release() + await didReturn + }) + + it('terminates active sockets on close and reports a repeated close', async () => { + let opened!: () => void + const didOpen = new Promise((resolve) => { opened = resolve }) + let returned!: () => void + const didReturn = new Promise((resolve) => { returned = resolve }) + const entry = await startMux(async (_endpoint, _payload, signal) => { + opened() + return cleanlyCancelled(signal, returned) + }) + const client = await connect(entry.url) + client.send(openFrame('active')) + await didOpen + + const closed = once(client, 'close') + await entry.mux.close() + running.delete(entry) + await closed + await didReturn + await expect(entry.mux.close()).rejects.toThrow() + await closeHttp(entry.http) + }) +}) + +const mapFailure: RemoteStreamFailureMapper = error => ({ + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, +}) + +async function startMux(open: RemoteStreamOpener): Promise { + const mux = new RemoteStreamMuxServer(open, mapFailure) + const http = createServer() + http.on('upgrade', (request, socket, head) => { mux.handleUpgrade(request, socket, head) }) + await new Promise((resolve, reject) => { + http.once('error', reject) + http.listen(0, '127.0.0.1', () => { + http.off('error', reject) + resolve() + }) + }) + const address = http.address() + if (address === null || typeof address === 'string') throw new Error('fixture HTTP server has no TCP port') + const entry = { http, mux, url: `ws://127.0.0.1:${String(address.port)}` } + running.add(entry) + return entry +} + +async function connect(url: string): Promise { + const socket = new WebSocket(url) + await once(socket, 'open') + return socket +} + +function acceptedSocket(mux: RemoteStreamMuxServer): WebSocket { + const exposed = mux as unknown as { server: { clients: Set } } + const socket = [...exposed.server.clients][0] + if (socket === undefined) throw new Error('fixture mux has no accepted socket') + return socket +} + +function openFrame(streamId: string): string { + return JSON.stringify({ type: 'open', streamId, endpoint: 'fixture/follow', payload: {} }) +} + +async function *waitForAbort(signal: AbortSignal): AsyncIterable { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) +} + +async function *cleanlyCancelled(signal: AbortSignal, returned: () => void): AsyncIterable { + try { + await new Promise((resolve) => { + if (signal.aborted) resolve() + else signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } finally { + returned() + } +} + +async function *delayedItem( + released: Promise, + opened: () => void, + returned: () => void = () => {}, +): AsyncIterable { + try { + opened() + await released + yield 'item' + } finally { + returned() + } +} + +async function closeHttp(server: Server): Promise { + if (!server.listening) return + await new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined) resolve() + else reject(error) + }) + }) +} diff --git a/packages/api/gateway/tsconfig.client.json b/packages/api/gateway/tsconfig.client.json index bbf7d8b19f..31df1266af 100644 --- a/packages/api/gateway/tsconfig.client.json +++ b/packages/api/gateway/tsconfig.client.json @@ -6,7 +6,13 @@ "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" }, "files": [ - "src/client/index.ts" + "src/client/index.ts", + "src/client/journal-stream.ts", + "src/client/remote-events.ts", + "src/client/remote-stream.ts", + "src/client/snapshot-stream.ts", + "src/client/stream-client.ts", + "src/stream-protocol.ts" ], "references": [ { @@ -17,6 +23,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/crypto" } ] } diff --git a/packages/api/gateway/tsconfig.host.json b/packages/api/gateway/tsconfig.host.json index 14f16b5bf5..46d1b3a88d 100644 --- a/packages/api/gateway/tsconfig.host.json +++ b/packages/api/gateway/tsconfig.host.json @@ -8,6 +8,8 @@ "files": [ "src/index.ts", "src/invariant.ts", + "src/stream-protocol.ts", + "src/stream-server.ts", "src/types.ts" ], "references": [ @@ -23,6 +25,9 @@ { "path": "../../client/connection/tsconfig.host.json" }, + { + "path": "../../host/webserver" + }, { "path": "../../typert/protocol" } diff --git a/packages/api/remotes/README.i18n.yaml b/packages/api/remotes/README.i18n.yaml index b9ff0c0323..4cd5c6c9ea 100644 --- a/packages/api/remotes/README.i18n.yaml +++ b/packages/api/remotes/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/api/remotes/README.md -README.md: 18d39c6e86f114d2aac2f24e5d15c13335eab020 -README.zh.md: bf3dfbbaa6e0c7bd1da8398977837d4cb19d4688 +README.md: 7817f30c531abffeb9156e1afaf8c6fe0697431e +README.zh.md: 4529ee60289fc43978f9526d4abb3e2cbf073687 diff --git a/packages/api/remotes/README.md b/packages/api/remotes/README.md index 18d39c6e86..7817f30c53 100644 --- a/packages/api/remotes/README.md +++ b/packages/api/remotes/README.md @@ -1,21 +1,48 @@ +--- +description: "Application Remote assembly: selects typed Host capabilities and forwarded events for Client consumers." +kind: "package-reference" +--- + # @deepseek-ai/dsh-api-remotes English | [中文](README.zh.md) -Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns Agent/Session identity policy; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. +## Summary -`createApiRemoteAgentResolver()` reuses live Agents, resumes ordinary cold sessions, deduplicates concurrent resumes, preserves the subagent ownership fence, and configures the same resolver for Typert `agent` and `session` lookups. The standard Web API Proxy supplies its Agent defaults and scope setup, then uses the returned resolver for legacy methods, so migrated and unmigrated methods share one policy implementation. +Two-sided BFF for Host Remote capabilities selected by this application. The Host entry owns the forwarded-event selection and registers its application event source with API Gateway; the Client entry imports generated `/remote` artifacts as runtime values, mounts each contribution through `ctx.remote.$mount()`, and re-exports their declaration merges. Client business packages depend on this facade rather than the Gateway implementation or individual Remote runtime entries. -The current Client assembly mounts the Goal Remote contribution and the read-only Host plugin inventory contribution (`pluginInventory/list`). Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. +## Table of Contents -This package contains no transport or Host service discovery logic. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. +- [Use this package](#use-this-package) +- [Forwarded Host events](#forwarded-host-events) +- [Build boundary](#build-boundary) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) +----- + + +## Use this package + +[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.md) owns Agent and Session identity policy, including the Typert lookup resolvers used by other namespaces. This package only selects and mounts that generated Session contribution; it does not duplicate activation policy. + +The Client assembly mounts Commands, Goal, dynamic Cordis, file and Session references, read-only Host plugin inventory, message feedback, Session Controller, and Workspace Controller contributions. Cordis effect ownership withdraws every contribution when this assembly unloads, while `@deepseek-ai/dsh-api-gateway/client` owns descriptor validation, traced namespace Services, direct and scoped methods, invocation, streams, and cancellation. The Client entry consumes the shared `TypertClientRemote` interface through Cordis and does not import the concrete Gateway. It re-exports the Gateway Client face's declaration merges type-only, so a consumer reaching the forwarded-event vocabulary through this facade gains no runtime edge to the Gateway implementation. + +This package owns no physical transport or Host service discovery. It projects the application selection into generated Remote contributions and an independent Host event source per Client; API Gateway owns endpoints, carriers, cancellation, and reconnection. Its Client face can be reused by Web or a future TUI that provides the same React-free `ctx.remote` contract. + +----- + + ## Forwarded Host events -`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host cordis events this application forwards to consumers verbatim — no projection, no redaction, no renaming — and therefore the legal key set of `ctx.remote.$on`; the type-only `src/types.ts` derives its selection face. Forwarding one more event is an entry in that array and nothing else: the type projection, the consumer key face, and the Host forwarding loop all derive from it. +`src/remote-events.ts` holds `API_REMOTE_FORWARDED_EVENTS`, the allowlist of Host Cordis events this application forwards without renaming, and therefore the legal key set of `ctx.remote.$on`; each entry also selects ordinary emission or Agent-scoped waterfall delivery. The type-only `src/types.ts` derives its selection face. Forwarding one more event requires one entry in that array: the type projection, consumer key face, and Host forwarding loop all derive from it. -The listener signature is not restated here. Each allowlisted event's cordis `Events` declaration lives in its owner package's client-safe `./types` export (`dsh-agent-presets`, `dsh-commands`, `dsh-credentials`, `dsh-llm`, `dsh-settings`), and both faces of this package pull those declarations in, so "forwarded verbatim" holds by construction rather than by proof. The Host face additionally asserts the list against `TypertForwardableEvent`, which rejects a name that is not a declared event, one that binds an AgentScope, and one whose shape is not one-way. +The listener signature is not restated here. Each allowlisted event's Cordis `Events` declaration lives in its owner package's client-safe `./types` export, and both faces of this package pull those declarations in. The Host face additionally asserts every entry against `TypertForwardableEventEntry`: an `emit` entry must be a declared one-way event, while a `waterfall` entry must be a declared Agent-scoped waterfall whose final parameter is its same-result `next()` callback. +The Host entry registers an independent allowlist listener set and queue for each Client stream. It rejects non-JSON ordinary-event arguments before enqueueing. For a waterfall, it projects only the top-level Agent identity and JSON request fields; a Client result must also be lossless JSON, while `next()` delegates to the following Host listener. The source attaches all listeners synchronously before `ctx.typertGateway.registerRemoteEvents()` exposes Gateway's internal `$events` logical stream, so its first `ready` item proves that incremental delivery is active. Withdrawing the registration aborts active streams; API Proxy does not participate in event forwarding or Connection generation. + + ## Build boundary An ordinary repository package belongs to one TypeScript face: Host packages are registered in the root `tsconfig.host.json`, and Client packages in the root `tsconfig.client.json`. `api-remotes` is the only deliberate exception because its Host entry must participate in the Host Typert graph, while `src/client/index.ts` cannot compile until Host tsdown has generated the business packages' `/remote` declarations. @@ -26,9 +53,10 @@ That exception is not just a `files` entry. The root `tsconfig.base.json` maps ` The package-local `clientBundle(..., { hostPhase: true })` makes Host tsdown bundle the Host entry and the later Client tsdown bundle only the browser entry. Ordinary Client plugins remain single Client projects and produce both their Node loader entry and browser bundle during Client tsdown; do not copy this package's split merely because a package has both `src/index.ts` and `src/client/index.ts`. + ## Model Experience -None, as this BFF selects Remote application methods and identity policy but registers nothing model-facing. +None, as this BFF selects Remote application methods and forwarded events but registers nothing model-facing. #### KV Cache effect @@ -36,6 +64,19 @@ No direct effect; mounted Host capabilities own any model-visible behavior they ## Known Limitations and Deferred Work + + - The capability set is fixed by explicit build-time value imports; the Client does not discover the Host's active Services or Remote definitions at runtime. - Additional capabilities require an explicit `/remote` value import and mount in this assembly. -- The standard Web Host supplies resume defaults and Agent-scope setup from the legacy API Proxy until that remaining BFF configuration moves into `api-remotes`. +- Ordinary forwarded events are not replayed; state that requires reliable recovery needs an owner-provided query, cursor, or opening baseline. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/remotes/README.zh.md b/packages/api/remotes/README.zh.md index bf3dfbbaa6..4529ee6028 100644 --- a/packages/api/remotes/README.zh.md +++ b/packages/api/remotes/README.zh.md @@ -1,21 +1,48 @@ +--- +description: "应用 Remote 装配:为 Client 消费方选择带类型的 Host 能力与转发事件。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-api-remotes [English](README.md) | 中文 -为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口负责 Agent/Session 身份策略;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 +## 概述 -`createApiRemoteAgentResolver()` 会复用 live Agent、恢复普通冷会话、对并发恢复去重、保留 subagent ownership fence,并为 Typert `agent` 和 `session` lookup 配置同一个 resolver。标准 Web API Proxy 提供 Agent 默认值和 scope 设置,再将返回的 resolver 用于旧方法,使已迁移与未迁移方法共用同一份策略实现。 +为本应用选定的 Host Remote 能力提供双侧 BFF。Host 入口拥有转发事件名单并向 API Gateway 注册应用事件 source;Client 入口以运行时值形式导入生成的 `/remote` 产物,通过 `ctx.remote.$mount()` 挂载每项贡献,并重新导出对应的声明合并。Client 业务包依赖该外观,而不依赖 Gateway 实现或单独的 Remote 运行时入口。 -当前 Client 组合挂载 Goal Remote 贡献和只读 Host 插件清单贡献(`pluginInventory/list`)。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 +## 目录 -本包不包含传输逻辑或 Host 服务发现逻辑。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 +- [使用本包](#use-this-package) +- [转发的 Host 事件](#forwarded-host-events) +- [构建边界](#build-boundary) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) +----- + + +## 使用本包 + +[`@deepseek-ai/dsh-api-session-controller`](../session-controller/README.zh.md) 拥有 Agent 与 Session 身份策略,包括供其他 namespace 使用的 Typert lookup resolver。本包只选择并挂载生成的 Session contribution,不复制激活策略。 + +Client 组合挂载 Commands、Goal、动态 Cordis、文件与 Session 引用、只读 Host 插件清单、消息反馈、Session Controller 和 Workspace Controller contribution。该组合卸载时,Cordis effect 的所有权机制会撤回所有贡献;`@deepseek-ai/dsh-api-gateway/client` 负责描述符校验、可追踪 namespace Service、直接与作用域方法、调用、流与取消。Client 入口通过 Cordis 消费共享的 `TypertClientRemote` 接口,不导入具体 Gateway;它只以 type-only 形式重新导出 Gateway Client face 的声明合并,因此消费端经由本外观取到转发事件词汇时,运行时不会多出一条通往 Gateway 实现的边。 + +本包不拥有物理传输或 Host 服务发现。它只把应用选择投影为生成的 Remote contribution 和唯一的 Host Cordis event source;API Gateway 负责 endpoint、carrier、取消与重连。Web 或未来的 TUI 只要提供同一份不依赖 React 的 `ctx.remote` 约定,均可复用其 Client face。 + +----- + + ## 转发的 Host 事件 -`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`——本应用原样转发给消费端的 Host cordis 事件名单(无投影、无脱敏、无改名),它同时就是 `ctx.remote.$on` 的合法键集;只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一行:类型投影、消费端键面与 Host 转发循环全部由它派生。 +`src/remote-events.ts` 持有 `API_REMOTE_FORWARDED_EVENTS`,即本应用不改名转发给消费端的 Host Cordis 事件名单;每个条目还会选择普通发送或 Agent-scoped waterfall 投递。该名单同时就是 `ctx.remote.$on` 的合法键集,只含类型的 `src/types.ts` 派生其选择面。多转发一个事件只需在该数组里加一项:类型投影、消费端键面与 Host 转发循环全部由它派生。 -监听器签名不在此处重写。名单内每条事件的 cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口(`dsh-agent-presets`、`dsh-commands`、`dsh-credentials`、`dsh-llm`、`dsh-settings`),本包两个 face 都把那些声明纳入编译面,因此「原样转发」是构造性成立的,不需要另立证明。Host face 还额外把名单断言给 `TypertForwardableEvent`:未声明的事件名、绑定 AgentScope 的事件、以及形状不是单向的事件都会在此被拒绝。 +监听器签名不在此处重写。名单内每条事件的 Cordis `Events` 声明都住在其 owner 包 client-safe 的 `./types` 出口,本包两个 face 都把那些声明纳入编译面。Host face 还会把每个条目断言给 `TypertForwardableEventEntry`:`emit` 条目必须是已声明的单向事件,`waterfall` 条目则必须是已声明的 Agent-scoped waterfall,且其最后一个参数是返回相同结果类型的 `next()` 回调。 +Host entry 为每条 Client stream 独立注册 allowlist listener 和队列,并在普通事件入队前拒绝非 JSON 参数。对于 waterfall,它只投影顶层 Agent 身份与 JSON 请求字段;Client 结果也必须能无损表示为 JSON,而 `next()` 会委托给后续 Host listener。该 source 在 `ctx.typertGateway.registerRemoteEvents()` 暴露 Gateway 内部的 `$events` logical stream 前同步挂好所有 listener,因此首个 `ready` 项能证明增量投递已就绪。撤回注册会中止活动 stream;API Proxy 不参与事件转发或 Connection generation。 + + ## 构建边界 仓库中的普通包只属于一个 TypeScript face:Host 包登记在根 `tsconfig.host.json`,Client 包登记在根 `tsconfig.client.json`。`api-remotes` 是唯一刻意拆分的特例,因为它的 Host 入口要参与 Host Typert 图,而 `src/client/index.ts` 必须等 Host tsdown 生成业务包的 `/remote` 声明后才能编译。 @@ -24,12 +51,12 @@ 这条例外不止是一行 `files`。根 `tsconfig.base.json` 把 `@deepseek-ai/dsh-api-remotes/types` 映射到 `src/types.ts`——**源平面**,与其余所有 workspace 子路径一致,也与生成的 `/remote` 产物相反(后者没有 `paths` 条目,靠 `exports` 命中构建产物)。于是两个 face 都把同一份名单与类型投影收进各自的 program,并向 `lib/types` 发射逐字相同的 `remote-events` 与 `types` 输出;`.tsbuildinfo` 仍各自独立。没有任何门禁强制两个 face 的源文件互不重叠——`scripts/project-reference-faces.ts` 只校验「引用一个 split project 必须指到对应 face」——因此本段记录这次双列为何是有意的。 - 包内 `clientBundle(..., { hostPhase: true })` 让 Host tsdown 打包 Host 入口,让后续 Client tsdown 只打包 browser 入口。普通 Client 插件仍使用单一 Client project,并在 Client tsdown 阶段一起生成 Node loader 入口和 browser bundle;不得因一个包同时存在 `src/index.ts` 与 `src/client/index.ts` 就复制本包的拆分。 + ## 模型体验 -无,因为该 BFF 只选择 Remote 应用方法和身份策略,不注册任何模型接口。 +无,因为该 BFF 只选择 Remote 应用方法和转发事件,不注册任何模型接口。 #### KV Cache 影响 @@ -37,6 +64,19 @@ ## 已知限制与暂缓事项 + + - 能力集合由构建时显式导入的值固定确定;Client 不会在运行时发现 Host 中已启用的服务或 Remote 定义。 - 若要增加能力,必须显式导入相应的 `/remote` 值并在此组合中挂载。 -- 在剩余 BFF 配置迁移到 `api-remotes` 之前,标准 Web Host 仍从旧 API Proxy 提供恢复默认值与 Agent scope 设置。 +- 只有仍在等待的作用域 waterfall 会在重连后重放;单向通知仍是相互隔离的 best-effort 投递。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0bc596bf71..836c54e2ab 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", - "description": "Remote BFF assembly and Host Agent/Session lookup policy", - "version": "0.1.0-rc.7", + "description": "Remote BFF assembly for application-selected Host capabilities", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -55,42 +55,51 @@ "lib/types/**/*.d.ts" ], "dependencies": { + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-typert-protocol": "workspace:^" }, "peerDependencies": { "@deepseek-ai/cordis": "workspace:^", - "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^" + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" }, "devDependencies": { - "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-credentials": "workspace:^", - "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-cordis-host-runner": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/dsh-agent-presets": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "workspace:^" + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/dsh-user-questions": "workspace:^" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts deleted file mode 100644 index 3551d6b1a8..0000000000 --- a/packages/api/remotes/src/agent-lookup.ts +++ /dev/null @@ -1,211 +0,0 @@ -/** Host BFF policy for resolving Remote Agent and Session identities. */ - -import type { Context } from '@deepseek-ai/cordis' -import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-session-persistence' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' -import type {} from '@deepseek-ai/dsh-typert-registry' - -/** Caller-facing failures preserved by the Gateway's RPC adapter. */ -export type ApiRemoteLookupError = - | { readonly code: 'agent-busy'; readonly message: string; readonly details: { readonly reason: string } } - | { readonly code: 'session-not-found'; readonly message: string; readonly details: { readonly sessionId: SessionId } } - | { readonly code: 'internal'; readonly message: string; readonly details: Record } - -/** Result of resolving one session identity to its live Agent. */ -export type ApiRemoteAgentResult = - | { readonly agent: Agent } - | { readonly error: ApiRemoteLookupError } - -/** Resume configuration supplied by the owning Host composition. */ -export interface ApiRemoteAgentOptions { - /** Read the per-Agent defaults when a cold identity must resume. */ - readonly agentOptions?: () => AgentOptions - /** - * Build the Host-specific Agent-scope composition completed before - * publication. Keyed by the resumed session itself because what a Host - * installs may depend on what that session recorded: an agent preset fixes - * the tools its history was produced under, so rebuilding it under another - * composition would replay tool calls the agent can no longer make. The - * events come along because a session's own record of such a choice may be - * an event rather than a header field. - * @param session - the resumed session's persisted header and event log. - * @returns the Agent-scope setup to run before publication. - */ - readonly setup?: ( - session: { meta: SessionHeader; events: readonly SessionEvent[] }, - ) => AgentSetup | Promise -} - -/** Cold identity absent from the durable session store. */ -export class ApiRemoteSessionNotFound extends Error {} - -/** Session identity whose lifecycle belongs to subagent routing. */ -export class ApiRemoteSubagentSessionOwnership extends Error { - /** - * Construct the ownership fence. - * @param sessionId - identity reserved to subagent routing. - */ - constructor(readonly sessionId: SessionId) { - super(`session "${sessionId}" is a subagent session; use subagent delivery`) - } -} - -/** - * Test whether generic Host routing must leave an identity to subagent routing. - * @param ctx - Host Context carrying the live Agent registry. - * @param session - attached or live Session metadata. - * @param agent - live Agent when one is registered. - * @returns whether generic Remote and legacy API calls must reject the identity. - */ -export function hasApiRemoteSubagentOwner( - ctx: Context, - session: Pick, - agent: Agent | undefined, -): boolean { - if (session.header.origin === 'subagent') return true - const parentId = session.header.parentSession - if (parentId === undefined || agent === undefined) return false - const parent = ctx.agents.get(parentId) - return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) -} - -/** - * Build the stable caller-facing ownership rejection. - * @param sessionId - identity reserved to subagent routing. - * @returns the existing `agent-busy` RPC shape. - */ -export function apiRemoteSubagentOwnershipError(sessionId: SessionId): ApiRemoteLookupError { - return { - code: 'agent-busy', - message: `session "${sessionId}" is owned by subagent routing`, - details: { reason: 'use subagent delivery for this child session' }, - } -} - -/** - * Inspect one cold served session without repairing, resuming, or publishing it. - * @param ctx - Host Context carrying the optional persistence provider. - * @param sessionId - durable identity to inspect. - * @returns detached metadata and events for a servable session. - * @throws {@link ApiRemoteSessionNotFound} when the identity has no project-backed session. - */ -export async function inspectApiRemoteSession( - ctx: Context, - sessionId: SessionId, -): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) { - throw new Error('session persistence is not configured (load a dsh-session-persistence backend)') - } - const meta = (await persistence.list()).find(candidate => candidate.id === sessionId) - if (meta === undefined || meta.cwd === undefined) { - throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) - } - const inspected = await persistence.inspect(sessionId) - if (inspected.meta.cwd === undefined) { - throw new ApiRemoteSessionNotFound(`session "${sessionId}" not found`) - } - return { meta: inspected.meta, events: [...inspected.events] } -} - -/** - * Create the Host's shared Agent resolver and configure Agent/Session Typert lookups. - * Live Agents are reused, ordinary cold sessions resume once per identity, and - * subagent-owned identities retain the legacy `agent-busy` fence. - * @param ctx - owning Host Context. - * @param options - defaults and Agent-scope setup used only for cold resume. - * @returns resolver shared by legacy API Proxy methods and Typert lookups. - */ -export function createApiRemoteAgentResolver( - ctx: Context, - options: ApiRemoteAgentOptions, -): (sessionId: SessionId) => Promise { - const resumes = new Map>() - - const fencedLiveAgent = (sessionId: SessionId): ApiRemoteAgentResult | undefined => { - const live = ctx.agents.get(sessionId) - if (live === undefined) return undefined - if (hasApiRemoteSubagentOwner(ctx, live.session, live)) { - return { error: apiRemoteSubagentOwnershipError(sessionId) } - } - return { agent: live } - } - - const agentFor = async (sessionId: SessionId): Promise => { - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { - return { error: apiRemoteSubagentOwnershipError(sessionId) } - } - let resume = resumes.get(sessionId) - if (resume === undefined) { - resume = (async () => { - try { - const inspected = await inspectApiRemoteSession(ctx, sessionId) - if (hasApiRemoteSubagentOwner(ctx, { header: inspected.meta }, undefined)) { - throw new ApiRemoteSubagentSessionOwnership(sessionId) - } - // Built from the inspected session before the published re-checks - // below, so those stay adjacent to `resume` and a Host setup that - // awaits (composing a preset, say) does not widen the collision - // window. - const setup = options.setup === undefined ? undefined : await options.setup(inspected) - const publishedSession = ctx.sessions.get(sessionId) - const publishedAgent = ctx.agents.get(sessionId) - if (publishedSession !== undefined - && hasApiRemoteSubagentOwner(ctx, publishedSession, publishedAgent)) { - throw new ApiRemoteSubagentSessionOwnership(sessionId) - } - const handle = await ctx.agents.resume({ - resumeSessionId: sessionId, - ...options.agentOptions === undefined ? {} : { agentOptions: options.agentOptions() }, - ...setup === undefined ? {} : { setup }, - }) - return handle.agent - } finally { - resumes.delete(sessionId) - } - })() - resumes.set(sessionId, resume) - } - try { - return { agent: await resume } - } catch (error: unknown) { - if (error instanceof ApiRemoteSessionNotFound) { - return { error: { code: 'session-not-found', message: error.message, details: { sessionId } } } - } - if (error instanceof ApiRemoteSubagentSessionOwnership) { - return { error: apiRemoteSubagentOwnershipError(error.sessionId) } - } - const fenced = fencedLiveAgent(sessionId) - if (fenced !== undefined) return fenced - const attached = ctx.sessions.get(sessionId) - if (attached !== undefined && hasApiRemoteSubagentOwner(ctx, attached, undefined)) { - return { error: apiRemoteSubagentOwnershipError(sessionId) } - } - return { - error: { - code: 'internal', - message: `resume failed for session "${sessionId}": ${String(error)}`, - details: {}, - }, - } - } - } - - ctx.inject(['typert'], (typeCtx) => { - const resolveAgent = async (sessionId: SessionId): Promise => { - const found = await agentFor(sessionId) - if ('error' in found) throw new TypertLookupFailure(found.error) - return found.agent - } - typeCtx.typert.lookups.configure('agent', resolveAgent) - typeCtx.typert.lookups.configure('session', async sessionId => (await resolveAgent(sessionId)).session) - typeCtx.typert.contexts.configureHost('agent', async sessionId => (await resolveAgent(sessionId)).ctx) - }) - - return agentFor -} diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 210ec0bf80..14be075879 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,19 +1,35 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from '@deepseek-ai/cordis' +import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' +import fileReferencesRemote from '@deepseek-ai/dsh-file-reference/remote' import pluginInventoryRemote from '@deepseek-ai/dsh-host-plugin-inventory/remote' import messageFeedbackRemote from '@deepseek-ai/dsh-message-feedback/remote' -import type { TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import sessionReferencesRemote from '@deepseek-ai/dsh-session-reference/remote' +import subagentsRemote from '@deepseek-ai/dsh-subagent/remote' +import sessionRemote from '@deepseek-ai/dsh-api-session-controller/remote' +import workspaceRemote from '@deepseek-ai/dsh-api-workspace-controller/remote' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' -export type { TypertClientRemote as ClientRemote } from '@deepseek-ai/dsh-typert-protocol' +export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' +export type {} from '@deepseek-ai/dsh-agent-presets/remote' export type {} from '@deepseek-ai/dsh-commands/remote' +export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' export type {} from '@deepseek-ai/dsh-host-plugin-inventory/remote' export type {} from '@deepseek-ai/dsh-message-feedback/remote' +export type {} from '@deepseek-ai/dsh-session-reference/remote' +export type {} from '@deepseek-ai/dsh-subagent/remote' +export type * from '@deepseek-ai/dsh-subagent/client' +export type {} from '@deepseek-ai/dsh-api-session-controller/remote' +export type * from '@deepseek-ai/dsh-api-session-controller/types' +export type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +export type * from '@deepseek-ai/dsh-api-workspace-controller/types' +export type { SessionJob as JobView } from '@deepseek-ai/dsh-api-session-controller/types' // The forwarded-event allowlist's selection seat: without it in the consumer's // compilation face `TypertRemoteEvent` is `never` and every `$on` call fails. export type { ApiRemoteForwardedEvent } from '../types.ts' @@ -26,6 +42,9 @@ export type {} from '@deepseek-ai/dsh-credentials/types' export type {} from '@deepseek-ai/dsh-llm/types' export type {} from '@deepseek-ai/dsh-agent-presets/types' export type {} from '@deepseek-ai/dsh-settings/types' +export type {} from '@deepseek-ai/dsh-user-approval/types' +export type {} from '@deepseek-ai/dsh-user-questions/types' +export type {} from '@deepseek-ai/dsh-api-session-controller/types' /** * The carrier's Client-facing types, re-exported so a business package names one @@ -33,14 +52,11 @@ export type {} from '@deepseek-ai/dsh-settings/types' * the carrier's runtime values stay behind their own module edge. */ export type { - ClientResponse, ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, - CredentialView, DirectoryListing, DiscoveredModelView, HistoryEntry, HostFrame, IApiClient, - MessageId, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, - MuxFrame, PromptContentPart, QuestionResponsePayload, QueueAction, RpcError, RpcId, RpcReceipt, - RpcRequest, RpcResponse, RpcResult, SessionId, SessionModels, SessionSearchItem, - SessionSummary, SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, - SubagentAddress, SubagentCatalog, JobView, ToolCallView, ToolEventView, ToolResultView, - WorkspaceId, WorkspaceView, + ConfigurableProviderView, ConnectionHandle, ConnectionSinks, ContentBlock, + CredentialView, DirectoryListing, DiscoveredModelView, IApiClient, + MessageId, ModelCatalog, ModelCatalogFailure, ModelProviderGroup, ModelReasoningEffort, ModelSelection, + RpcError, RpcId, RpcRequest, RpcResponse, RpcResult, SessionId, + SettingsNamespaceView, SettingsPathOpView, SkillEntry, StreamChunk, } from '@deepseek-ai/dsh-client-connection/client' export type {} from '@deepseek-ai/dsh-api-gateway/client' export type {} from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -86,11 +102,28 @@ export type { // reason: a Client contribution names what it sends without importing a Host // package, and this assembly is where both planes legitimately meet. export type { JsonValue } from '@deepseek-ai/dsh-session/types' +// Reference-discovery result vocabulary for the fileReferences and +// sessionReferenceResolver namespaces. +export type { FileReferenceCandidate } from '@deepseek-ai/dsh-file-reference/types' +export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session-reference/types' + +/** Failure vocabulary exposed by the assembled Client data layer. */ +export type ClientFailure = + | import('@deepseek-ai/dsh-client-connection/client').RpcError + | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError + | import('@deepseek-ai/dsh-api-session-controller/types').SessionError + | import('@deepseek-ai/dsh-subagent/client').SubagentControlError + | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError + +/** Success or failure returned by Client operations spanning both API families. */ +export type ClientResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ClientFailure } declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by this Client assembly. */ - remote: TypertClientRemote + remote: ClientRemote } } @@ -106,7 +139,9 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, pluginInventoryRemote, messageFeedbackRemote, + agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, + subagentsRemote, sessionRemote, workspaceRemote, ]) { disposers.push(await ctx.remote.$mount(contribution)) } diff --git a/packages/api/remotes/src/index.ts b/packages/api/remotes/src/index.ts index 6572c11938..4d0256e162 100644 --- a/packages/api/remotes/src/index.ts +++ b/packages/api/remotes/src/index.ts @@ -1,6 +1,15 @@ /** Host BFF entry and Loader shell for the Remote contribution assembly. */ -import type { TypertForwardableEvent } from '@deepseek-ai/dsh-typert-protocol' +import type { Context } from '@deepseek-ai/cordis' +import type { + TypertRemoteEventDispatch, + TypertRemoteEventInvocation, + TypertRemoteEventOutcome, + TypertRemoteEventSource, +} from '@deepseek-ai/dsh-api-gateway' +import { carrierKeyOf } from '@deepseek-ai/dsh-scope' +import { isJsonValue } from '@deepseek-ai/dsh-session' +import type { JsonValue } from '@deepseek-ai/dsh-session' import { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' // The owner packages' client-safe `./types` exports carry the cordis `Events` @@ -13,32 +22,143 @@ import type {} from '@deepseek-ai/dsh-credentials/types' import type {} from '@deepseek-ai/dsh-llm/types' import type {} from '@deepseek-ai/dsh-agent-presets/types' import type {} from '@deepseek-ai/dsh-settings/types' +import type {} from '@deepseek-ai/dsh-user-approval' +import type {} from '@deepseek-ai/dsh-user-questions' +export type {} from '@deepseek-ai/dsh-api-session-controller/types' -export { - ApiRemoteSessionNotFound, - ApiRemoteSubagentSessionOwnership, - apiRemoteSubagentOwnershipError, - createApiRemoteAgentResolver, - hasApiRemoteSubagentOwner, - inspectApiRemoteSession, -} from './agent-lookup.ts' -export type { - ApiRemoteAgentOptions, - ApiRemoteAgentResult, - ApiRemoteLookupError, -} from './agent-lookup.ts' export { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' export type { ApiRemoteForwardedEvent } from './types.ts' -// Shape gate over the allowlist, kept in the Host face because the Host's event -// vocabulary is the authoritative one. It pins three things at compile time: -// every entry NAMES a declared event (the predicate is keyed on `keyof -// Events`), no entry BINDS a Scope (a scoped event's `ThisParameterType` is not -// `unknown`, which is how "must not depend on AgentScope" is stated statically), -// and every entry is ONE-WAY (a waterfall or bail shape returns something other -// than void and is excluded). Widening the array to an event that fails any of -// these fails here, not on the wire. -API_REMOTE_FORWARDED_EVENTS satisfies readonly TypertForwardableEvent[] +/** Required Host service: the Gateway owns the physical Remote stream mux. */ +export const inject = ['typertGateway'] -/** Host plugin body; the selected contributions mount only in Client environments. */ -export function apply(): void {} +/** Host plugin body registering this application's selected Cordis event source. */ +export function apply(ctx: Context): void { + ctx.effect( + () => ctx.typertGateway.registerRemoteEvents(remoteEventSource(ctx)), + 'api-remotes: forwarded Cordis event source', + ) +} + +/** Create the sole queue and listener set consumed by the registered Gateway. */ +function remoteEventSource(ctx: Context): TypertRemoteEventSource { + return (signal) => { + const queue = new RemoteEventQueue() + const disposers = API_REMOTE_FORWARDED_EVENTS.map(({ event, mode }) => { + if (mode === 'emit') { + return ctx.on(event as never, ((...args: unknown[]) => { + queue.push({ event, args: assertJsonArgs(event, args) }) + }) as never) + } + return ctx.on(event as never, (function ( + this: unknown, + request: object, + next: () => unknown, + ) { + const subject = carrierKeyOf(this) + if (subject === undefined) return next() + const value = Reflect.get(subject, 'ctx') as unknown + if (typeof value !== 'object' || value === null) { + throw new TypeError(`forwarded scoped event ${JSON.stringify(event)} has no live Context`) + } + return forwardWaterfall( + queue, + event, + request, + { value: value as Context, subject }, + next, + ) + }) as never) + }) + return queue.iterate(signal, () => { + for (const dispose of disposers) dispose() + }) + } +} + +/** One pull-driven queue bridging synchronous Cordis listeners to an AsyncIterable. */ +class RemoteEventQueue { + private readonly buffer: TypertRemoteEventDispatch[] = [] + private waiter: (() => void) | undefined + private done = false + + push(frame: TypertRemoteEventDispatch): boolean { + if (this.done) return false + this.buffer.push(frame) + this.waiter?.() + return true + } + + private end(reason: unknown): void { + if (this.done) return + this.done = true + const buffered = this.buffer.splice(0) + for (const dispatch of buffered) { + if ('context' in dispatch) dispatch.reject(reason) + } + this.waiter?.() + } + + async *iterate(signal: AbortSignal, cleanup: () => void): AsyncGenerator { + const abort = (): void => { this.end(remoteEventSourceEndReason(signal)) } + signal.addEventListener('abort', abort, { once: true }) + try { + while (true) { + if (this.done || signal.aborted) return + while (this.buffer.length > 0) yield this.buffer.shift() as TypertRemoteEventDispatch + await new Promise((resolve) => { this.waiter = resolve }) + this.waiter = undefined + } + } finally { + signal.removeEventListener('abort', abort) + this.end(remoteEventSourceEndReason(signal)) + cleanup() + } + } +} + +/** + * Normalize an event-source shutdown for pending Host waterfalls. + * @param signal - source lifetime whose reason wins after cancellation. + * @returns the cancellation reason or an unexpected-end failure. + */ +function remoteEventSourceEndReason(signal: AbortSignal): unknown { + if (signal.aborted) return signal.reason + return new Error('api-remotes: forwarded Remote event source ended') +} + +/** Bridge one Cordis waterfall listener through the Gateway-owned pending event. */ +function forwardWaterfall( + queue: RemoteEventQueue, + event: string, + request: object, + context: TypertRemoteEventInvocation['context'], + next: () => unknown, +): Promise { + const settled = Promise.withResolvers() + const dispatch: TypertRemoteEventInvocation = { + event, + request, + context, + resolve: (outcome: TypertRemoteEventOutcome) => { + if (outcome.kind === 'result') { + settled.resolve(outcome.value) + return + } + void Promise.resolve().then(next).then(settled.resolve, settled.reject) + }, + reject: settled.reject, + } + if (!queue.push(dispatch)) void Promise.resolve().then(next).then(settled.resolve, settled.reject) + return settled.promise +} + +/** Reject an allowlisted event whose runtime arguments are not lossless JSON data. */ +function assertJsonArgs(event: string, args: readonly unknown[]): JsonValue[] { + for (const [index, arg] of args.entries()) { + if (!isJsonValue(arg)) { + throw new Error(`forwarded host event "${event}" argument ${String(index)} is not lossless JSON data`) + } + } + return args as JsonValue[] +} diff --git a/packages/api/remotes/src/remote-events.ts b/packages/api/remotes/src/remote-events.ts index 2174336532..bf98fe536c 100644 --- a/packages/api/remotes/src/remote-events.ts +++ b/packages/api/remotes/src/remote-events.ts @@ -6,24 +6,26 @@ * type-only. */ +import { SESSION_CONTROLLER_REMOTE_EVENTS } from '@deepseek-ai/dsh-api-session-controller/remote-events' +import type { TypertForwardableEventEntry } from '@deepseek-ai/dsh-typert-protocol' + /** - * Host events this application forwards to consumers verbatim: no projection, - * no redaction, no renaming. The wire name is the Host cordis event name and - * the payload is its argument list, so this array is simultaneously the whole - * control point over what a consumer can receive and the legal key set of - * `ctx.remote.$on`. Forwarding one more event is an entry here and nothing - * else. + * Host events this application forwards without renaming. The explicit mode is + * both the Host dispatch strategy and the legal key set of `ctx.remote.$on`. */ export const API_REMOTE_FORWARDED_EVENTS = [ - 'agent-preset/selected', - 'commands/change', - 'credentials/updated', - 'cordis/request-run', - 'cordis/request-run-resolved', - 'cordis/dynamic-package', - 'cordis/dynamic-retract', - 'cordis/inspect-query', - 'cordis/inspect-query-resolved', - 'llm/adapters-updated', - 'settings/document-updated', -] as const + { event: 'agent-preset/selected', mode: 'emit' }, + { event: 'approval/request', mode: 'waterfall' }, + ...SESSION_CONTROLLER_REMOTE_EVENTS.map(event => ({ event, mode: 'emit' as const })), + { event: 'commands/change', mode: 'emit' }, + { event: 'credentials/reference-updated', mode: 'emit' }, + { event: 'cordis/request-run', mode: 'emit' }, + { event: 'cordis/request-run-resolved', mode: 'emit' }, + { event: 'cordis/dynamic-package', mode: 'emit' }, + { event: 'cordis/dynamic-retract', mode: 'emit' }, + { event: 'cordis/inspect-query', mode: 'emit' }, + { event: 'cordis/inspect-query-resolved', mode: 'emit' }, + { event: 'llm/adapters-updated', mode: 'emit' }, + { event: 'settings/document-updated', mode: 'emit' }, + { event: 'user-questions/request', mode: 'waterfall' }, +] as const satisfies readonly TypertForwardableEventEntry[] diff --git a/packages/api/remotes/src/types.ts b/packages/api/remotes/src/types.ts index 6e14546261..cbf7572d8a 100644 --- a/packages/api/remotes/src/types.ts +++ b/packages/api/remotes/src/types.ts @@ -12,7 +12,7 @@ import type { API_REMOTE_FORWARDED_EVENTS } from './remote-events.ts' /** Type projection of the allowlist; the consumer and the Host read this one. */ -export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number] +export type ApiRemoteForwardedEvent = typeof API_REMOTE_FORWARDED_EVENTS[number]['event'] declare module '@deepseek-ai/dsh-typert-protocol' { interface TypertRemoteEventSelection extends Record {} diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts deleted file mode 100644 index 743059e73c..0000000000 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ /dev/null @@ -1,154 +0,0 @@ -import { describe, expect, it, vi } from 'vitest' -import { Context } from '@deepseek-ai/cordis' -import AgentRegistry from '@deepseek-ai/dsh-agent' -import type { Agent } from '@deepseek-ai/dsh-agent' -import SessionStore from '@deepseek-ai/dsh-session' -import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' -import { createApiRemoteAgentResolver } from '@deepseek-ai/dsh-api-remotes' -import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' -import TypertRegistry from '@deepseek-ai/dsh-typert-registry' - -const sid = (value: string): SessionId => value as SessionId - -function header(id: SessionId): SessionHeader { - return { version: 0, id, createdAt: 1, cwd: '/proj' } -} - -async function createContext(): Promise { - const ctx = new Context() - await ctx.plugin(TypertRegistry) - await ctx.plugin(SessionStore) - await ctx.plugin(AgentRegistry) - return ctx -} - -function provideSession( - ctx: Context, - meta: SessionHeader, - inspect: () => Promise<{ meta: SessionHeader; events: SessionEvent[] }>, -): void { - ctx.provide('sessionPersistence', { - list: () => Promise.resolve([meta]), - inspect, - locate: () => undefined, - } as never) -} - -function stubAgent(ctx: Context, session: Session): Agent { - return { id: session.id, session, status: 'idle', ctx } as Agent -} - -describe('API Remote Agent resolver races', () => { - it('maps an inspected session without a cwd to session-not-found', async () => { - const ctx = await createContext() - const sessionId = sid('missing-after-inspect') - const meta = header(sessionId) - provideSession(ctx, meta, () => Promise.resolve({ - meta: { ...meta, cwd: undefined } as unknown as SessionHeader, - events: [], - })) - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ error: { code: 'session-not-found', details: { sessionId } } }) - await ctx.fiber.dispose() - }) - - it('resumes through a concurrently attached ordinary Session without optional defaults', async () => { - const ctx = await createContext() - const sessionId = sid('ordinary-attach-race') - const meta = header(sessionId) - let published: Session | undefined - provideSession(ctx, meta, () => { - published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) - return Promise.resolve({ meta, events: [] }) - }) - const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { - if (published === undefined) throw new Error('Session was not published') - return { agent: stubAgent(ctx, published), dispose: () => Promise.resolve() } - }) - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ agent: { id: sessionId } }) - expect(resume).toHaveBeenCalledWith({ resumeSessionId: sessionId }) - await ctx.fiber.dispose() - }) - - it('rejects a subagent Session published after durable inspection', async () => { - const ctx = await createContext() - const sessionId = sid('owned-attach-race') - const meta = header(sessionId) - provideSession(ctx, meta, () => { - ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) - return Promise.resolve({ meta, events: [] }) - }) - const resume = vi.spyOn(ctx.agents, 'resume') - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ error: { code: 'agent-busy' } }) - expect(resume).not.toHaveBeenCalled() - await ctx.fiber.dispose() - }) - - it('reclassifies failed resumes after a live or attached subagent wins publication', async () => { - for (const winner of ['agent', 'session'] as const) { - const ctx = await createContext() - const sessionId = sid(`owned-${winner}-resume-race`) - const meta = header(sessionId) - provideSession(ctx, meta, () => Promise.resolve({ meta, events: [] })) - vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { - const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) - if (winner === 'agent') ctx.agents.register(stubAgent(ctx, session)) - throw new Error('session id already published') - }) - - const result = await createApiRemoteAgentResolver(ctx, {})(sessionId) - - expect(result).toMatchObject({ error: { code: 'agent-busy' } }) - await ctx.fiber.dispose() - } - }) - - it('uses the shared cold-resume policy for the Agent Host Context', async () => { - const ctx = await createContext() - const sessionId = sid('context-cold-resume') - const meta = header(sessionId) - let published: Session | undefined - provideSession(ctx, meta, () => { - published = ctx.sessions.create(sessionId, { meta: { cwd: '/proj' } }) - return Promise.resolve({ meta, events: [] }) - }) - const agentCtx = ctx.extend() - vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { - if (published === undefined) throw new Error('Session was not published') - return { agent: stubAgent(agentCtx, published), dispose: () => Promise.resolve() } - }) - const defaultProvider = ctx.typert.contexts.getHost('agent') - createApiRemoteAgentResolver(ctx, {}) - await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) - const provider = ctx.typert.contexts.getHost('agent') - if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') - - await expect(provider.resolve(sessionId)).resolves.toBe(agentCtx) - await ctx.fiber.dispose() - }) - - it('applies the subagent ownership fence to the Agent Host Context', async () => { - const ctx = await createContext() - const sessionId = sid('context-owned-subagent') - const session = ctx.sessions.create(sessionId, { meta: { cwd: '/proj', origin: 'subagent' } }) - ctx.agents.register(stubAgent(ctx.extend(), session)) - const defaultProvider = ctx.typert.contexts.getHost('agent') - createApiRemoteAgentResolver(ctx, {}) - await vi.waitFor(() => { expect(ctx.typert.contexts.getHost('agent')).not.toBe(defaultProvider) }) - const provider = ctx.typert.contexts.getHost('agent') - if (provider === undefined) throw new Error('Agent Host Context provider was not mounted') - - const resolution = provider.resolve(sessionId) - await expect(resolution).rejects.toBeInstanceOf(TypertLookupFailure) - await expect(resolution).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) - await ctx.fiber.dispose() - }) -}) diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index 232cf9b2f6..ef7b4ae0ed 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -58,6 +58,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const { Session, SessionId } = await import(urls.session) const routes = [] + const credentialRecords = new Map() const host = new Context() host.provide('webServer', { register(route) { @@ -67,6 +68,15 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { tapIndex() { return () => {} }, port: 0, }) + host.provide('credentials', { + readRecord(key) { return Promise.resolve(credentialRecords.get(key)) }, + async modifyRecord(key, mutate) { + const current = credentialRecords.get(key) + const next = await mutate(current) + if (next !== undefined) credentialRecords.set(key, next) + return next ?? current + }, + }) await host.plugin({ inject: connectionHost.inject, apply: connectionHost.apply }) await host.plugin(TypertRegistry) await host.plugin(AgentRegistry) @@ -101,11 +111,30 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { if (routes.length !== 1 || routes[0].path !== '/api') { throw new Error('Connection did not register exactly one /api route') } - const server = createServer((request, response) => { void routes[0].handler(request, response) }) + const server = createServer((request, response) => { + if ((request.url ?? '/').startsWith('/?')) { + if (host.connection.authorizeIndex(request, response)) { + response.writeHead(200, { 'content-type': 'text/html' }) + response.end('shell') + } + return + } + void routes[0].handler(request, response) + }) await new Promise(resolveListen => server.listen(0, '127.0.0.1', resolveListen)) const address = server.address() if (address === null || typeof address === 'string') throw new Error('HTTP server has no TCP address') const origin = 'http://127.0.0.1:' + String(address.port) + const login = await fetch(host.connection.authenticatedUrl(origin), { redirect: 'manual' }) + const setCookie = login.headers.get('set-cookie') + if (login.status !== 303 || setCookie === null) throw new Error('browser token exchange failed') + const cookie = setCookie.split(';', 1)[0] + const hostFetch = globalThis.fetch + globalThis.fetch = (input, init = {}) => { + const headers = new Headers(init.headers) + headers.set('cookie', cookie) + return hostFetch(input, { ...init, headers }) + } const handoffs = new Map() globalThis.window = { diff --git a/packages/api/remotes/tests/remote-events.host.spec.ts b/packages/api/remotes/tests/remote-events.host.spec.ts new file mode 100644 index 0000000000..eefe64e664 --- /dev/null +++ b/packages/api/remotes/tests/remote-events.host.spec.ts @@ -0,0 +1,216 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import type { + TypertRemoteEventInvocation, + TypertRemoteEventSource, +} from '@deepseek-ai/dsh-api-gateway' +import { scopeTarget } from '@deepseek-ai/dsh-scope' +import { describe, expect, it } from 'vitest' +import { apply, inject } from '../src/index.ts' + +interface GatewayProbe { + source: TypertRemoteEventSource | undefined + removals: number + registerRemoteEvents(source: TypertRemoteEventSource): () => Promise +} + +async function setup(): Promise<{ + readonly ctx: Context + readonly gateway: GatewayProbe + readonly fiber: Fiber +}> { + const ctx = new Context() + const gateway: GatewayProbe = { + source: undefined, + removals: 0, + registerRemoteEvents(source) { + gateway.source = source + return async () => { + if (gateway.source !== source) return + gateway.source = undefined + gateway.removals += 1 + } + }, + } + ctx.reflect.provide('typertGateway', gateway) + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber + return { ctx, gateway, fiber } +} + +function sourceOf(gateway: GatewayProbe): TypertRemoteEventSource { + if (gateway.source === undefined) throw new Error('fixture Gateway has no Remote event source') + return gateway.source +} + +function emitRaw(ctx: Context, event: string, args: readonly unknown[]): void { + const emit = ctx.emit.bind(ctx) as unknown as (name: string, ...values: readonly unknown[]) => void + emit(event, ...args) +} + +function waterfallRaw( + ctx: Context, + target: object, + event: string, + args: readonly unknown[], + next: () => Promise, +): Promise { + const waterfall = ctx.waterfall.bind(ctx) as unknown as ( + receiver: object, + name: string, + ...values: readonly unknown[] + ) => Promise + return waterfall(target, event, ...args, next) +} + +function invocationOf(value: unknown): TypertRemoteEventInvocation { + if (typeof value !== 'object' || value === null || !Object.hasOwn(value, 'context')) { + throw new Error('fixture did not receive a scoped Remote Event invocation') + } + return value as TypertRemoteEventInvocation +} + +describe('Remote event Host source', () => { + it('gives each Client stream an independent allowlisted event queue', async () => { + const { ctx, gateway, fiber } = await setup() + const firstAbort = new AbortController() + const secondAbort = new AbortController() + const first = sourceOf(gateway)(firstAbort.signal)[Symbol.asyncIterator]() + const second = sourceOf(gateway)(secondAbort.signal)[Symbol.asyncIterator]() + + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1]) + await expect(first.next()).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 1] }, + }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 1] }, + }) + + const firstDone = first.next() + firstAbort.abort(new Error('first Client disconnected')) + emitRaw(ctx, 'commands/change', []) + await expect(firstDone).resolves.toEqual({ done: true, value: undefined }) + await expect(second.next()).resolves.toEqual({ + done: false, + value: { event: 'commands/change', args: [] }, + }) + + const secondDone = second.next() + secondAbort.abort(new Error('second Client disconnected')) + await expect(secondDone).resolves.toEqual({ done: true, value: undefined }) + + await fiber.dispose() + expect(gateway.source).toBeUndefined() + expect(gateway.removals).toBe(1) + await ctx.fiber.dispose() + }) + + it('rejects a non-JSON argument without poisoning the stream', async () => { + const { ctx, gateway } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const pending = iterator.next() + + expect(() => { + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 1n]) + }).toThrow('argument 1 is not lossless JSON data') + emitRaw(ctx, 'settings/document-updated', ['ui-theme', 2]) + await expect(pending).resolves.toEqual({ + done: false, + value: { event: 'settings/document-updated', args: ['ui-theme', 2] }, + }) + + const done = iterator.next() + abort.abort() + await expect(done).resolves.toEqual({ done: true, value: undefined }) + + const alreadyAborted = new AbortController() + alreadyAborted.abort() + await expect(sourceOf(gateway)(alreadyAborted.signal)[Symbol.asyncIterator]().next()) + .resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('bridges scoped waterfall result, next delegation, and rejection', async () => { + const { ctx, gateway } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const agentCtx = ctx.extend() + const agent = { ctx: agentCtx } + const target = scopeTarget(ctx, agent) + const request = { questions: [], agent } + + const claimed = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const claimedDispatch = invocationOf((await iterator.next()).value) + expect(claimedDispatch).toMatchObject({ + event: 'user-questions/request', + request, + context: { value: agentCtx, subject: agent }, + }) + claimedDispatch.resolve({ kind: 'result', value: 'client answer' }) + await expect(claimed).resolves.toBe('client answer') + + const delegated = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const delegatedDispatch = invocationOf((await iterator.next()).value) + delegatedDispatch.resolve({ kind: 'next' }) + await expect(delegated).resolves.toBe('host fallback') + + const rejection = Object.assign(new Error('the user cancelled ask_user_question'), { + code: 'ASK_CANCELLED', + }) + const rejected = waterfallRaw( + ctx, + target, + 'user-questions/request', + [request], + () => Promise.resolve('host fallback'), + ) + const rejectedAssertion = expect(rejected).rejects.toBe(rejection) + const rejectedDispatch = invocationOf((await iterator.next()).value) + rejectedDispatch.reject(rejection) + await rejectedAssertion + + const done = iterator.next() + abort.abort() + await expect(done).resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('rejects a queued scoped waterfall when its source is withdrawn', async () => { + const { ctx, gateway, fiber } = await setup() + const abort = new AbortController() + const iterator = sourceOf(gateway)(abort.signal)[Symbol.asyncIterator]() + const delivery = iterator.next() + const agent = { ctx: ctx.extend() } + const reason = new Error('forwarded event source removed') + const pending = waterfallRaw( + ctx, + scopeTarget(ctx, agent), + 'user-questions/request', + [{ questions: [], agent }], + () => Promise.resolve('host fallback'), + ) + const rejected = expect(pending).rejects.toBe(reason) + + abort.abort(reason) + + await rejected + await expect(delivery).resolves.toEqual({ done: true, value: undefined }) + await fiber.dispose() + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/remotes/tsconfig.client.json b/packages/api/remotes/tsconfig.client.json index a0161dd047..9d738aa3f8 100644 --- a/packages/api/remotes/tsconfig.client.json +++ b/packages/api/remotes/tsconfig.client.json @@ -24,6 +24,12 @@ "path": "../../credentials/credentials" }, + { + "path": "../../context/file-reference" + }, + { + "path": "../../context/session-reference" + }, { "path": "../../extensions/cordis-host-runner" }, @@ -48,6 +54,21 @@ { "path": "../../settings/settings" }, + { + "path": "../../subagent/subagent" + }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../interaction/user-questions" + }, + { + "path": "../session-controller/tsconfig.client.json" + }, + { + "path": "../workspace-controller/tsconfig.client.json" + }, { "path": "../../typert/protocol" } diff --git a/packages/api/remotes/tsconfig.host.json b/packages/api/remotes/tsconfig.host.json index 61eae810f4..4dd513bdeb 100644 --- a/packages/api/remotes/tsconfig.host.json +++ b/packages/api/remotes/tsconfig.host.json @@ -6,7 +6,6 @@ "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" }, "files": [ - "src/agent-lookup.ts", "src/index.ts", "src/invariant.ts", "src/remote-events.ts", @@ -17,7 +16,7 @@ "path": "../../../vendor/cordis" }, { - "path": "../../core/agent" + "path": "../gateway/tsconfig.host.json" }, { "path": "../../core/session" @@ -34,20 +33,29 @@ { "path": "../../preset/agent-presets" }, - { - "path": "../../session/session-persistence" - }, { "path": "../../extensions/cordis-host-runner" }, { "path": "../../settings/settings" }, + { + "path": "../../core/scope" + }, + { + "path": "../../interaction/user-approval" + }, + { + "path": "../../interaction/user-questions" + }, { "path": "../../runtime-diagnostics/invariants" }, { - "path": "../../typert/registry" + "path": "../session-controller/tsconfig.host.json" + }, + { + "path": "../workspace-controller/tsconfig.host.json" }, { "path": "../../typert/protocol" diff --git a/packages/api/session-controller/README.i18n.yaml b/packages/api/session-controller/README.i18n.yaml new file mode 100644 index 0000000000..3325cc86a4 --- /dev/null +++ b/packages/api/session-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/session-controller/README.md +README.md: 815276847f538f99352e0f0e55fc19af5da67470 +README.zh.md: 51bf5a98c62aaa3fcb2156c029416a4d26515f0b diff --git a/packages/api/session-controller/README.md b/packages/api/session-controller/README.md new file mode 100644 index 0000000000..815276847f --- /dev/null +++ b/packages/api/session-controller/README.md @@ -0,0 +1,70 @@ +--- +description: "Host and Client session control: create, resume, prompt, follow history, and project live session state." +kind: "package-reference" +--- +# Session Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-session-controller` owns the Host `ctx.sessionController` service and the generated Client `ctx.remote.session` namespace. It serves Session list, search, creation, model selection, rename, fork, prompt, attachment, queue, cancellation, message-aligned history, live log following, and Host-wide control state. Use it through API Gateway when a Client needs these Session operations. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Configuration](#configuration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +History pages and follow opening snapshots carry a discriminated `SessionHistoryRecord`. Both variants use `{ type, event }`: `type: 'event'` carries one raw `SessionWireEvent`, while `type: 'chunks'` carries one lossless `ChunkRowEvent` for consecutive same-block `assistant/chunk` deltas. Both inner values expose `type`, `seq`, `time`, and `data`, so the Client retains each accepted record as one `SessionEventLikeEntry` without record-by-record conversion. A packed event's `seq` and `time` identify its first member, and `data` retains the fragment and timestamp-gap arrays. Live follow frames remain individual `event` records. Tool arguments, result content, failures, and `tool/result.data.meta` pass through unchanged; the controller does not resolve a Tool definition, run a presenter, or attach UI data. + +Each endpoint states its activation policy. List, search, attachment, history pages, and log following can inspect persistence without activating an Agent; queue mutation and cancellation require the corresponding live state; model, rename, and prompt commands may explicitly resume an ordinary Session. Create and fork are the only operations that create a new Agent. The service applies one preset-aware resume policy and subagent ownership fence to its own methods and to the Typert Agent and Session lookups used by other Remote namespaces. + +The Client adapter exposes `SessionEventStream`, a Gateway `RemoteJournalStream` bound to one ordinary or direct-subagent address. It opens follow before the initial page, publishes only contiguous `replace`, `prepend`, and `append` changes, and repairs reconnect or sequence gaps through a tail page. Ordinary records cover `[event.seq, event.seq]`; packed rows cover `[event.seq, event.seq + memberCount - 1]`. A business, persistence, or unresolved continuity failure terminates the stream, while only physical carrier loss selects automatic resumption. `SessionControlStream` is a Gateway `RemoteSnapshotStream`; every generation opens with a complete process-local baseline, so reconnect replaces queue, jobs, and projection state instead of treating transient values as durable events. + +----- + + +## Configuration + +| Field | Default | Meaning | +|---|---:|---| +| `coldBlankProbeMaxBytes` | `1,024` | Maximum physical size of a cold Session artifact eligible for blankness verification; `0` disables probes | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-api-session-controller) is the exhaustive source for accepted fields and their JSDoc. + +----- + + +## Model Experience + +None, as invoked Agent commands own any model-visible effect. + +#### KV Cache effect + +No direct effect; model requests remain owned by the Agent and LLM packages. + +## Known Limitations and Deferred Work + + + +- Control baselines represent process-local state and therefore cannot reconstruct jobs after a Host restart. +- A failed follow resumption remains visible to the caller instead of retrying indefinitely. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/session-controller/README.zh.md b/packages/api/session-controller/README.zh.md new file mode 100644 index 0000000000..51bf5a98c6 --- /dev/null +++ b/packages/api/session-controller/README.zh.md @@ -0,0 +1,70 @@ +--- +description: "Host 与 Client 会话控制:创建、恢复、提示、跟随历史并投影实时会话状态。" +kind: "package-reference" +--- +# Session Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-session-controller` 拥有 Host 的 `ctx.sessionController` 服务和生成的 Client `ctx.remote.session` namespace。它提供 Session 列表、搜索、创建、模型选择、重命名、fork、prompt、附件、queue、取消、按消息对齐的历史、live 日志跟随和 Host 范围 control 状态。当 Client 需要这些 Session 操作时,请通过 API Gateway 使用它。 + +## 目录 + +- [使用本包](#use-this-package) +- [配置](#configuration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +历史页与 follow opening snapshot 携带带判别字段的 `SessionHistoryRecord`。两个分支都使用 `{ type, event }`:`type: 'event'` 携带一个原始 `SessionWireEvent`,`type: 'chunks'` 则携带一个由连续且属于同一 block 的 `assistant/chunk` delta 组成的无损 `ChunkRowEvent`。两种内部值都公开 `type`、`seq`、`time` 与 `data`,因此 Client 无需逐 record 转换,就能把每条已接受 record 保留为一个 `SessionEventLikeEntry`。packed event 的 `seq` 与 `time` 表示首成员,`data` 保留 fragment 与 timestamp-gap 数组。实时 follow frame 继续携带单个 `event` record。工具参数、结果内容、失败信息和 `tool/result.data.meta` 原样通过;controller 不解析 Tool definition、不运行 presenter,也不附加 UI 数据。 + +每个 endpoint 都声明自己的激活策略。列表、搜索、附件、历史页和日志跟随可以在不激活 Agent 的情况下检查 persistence;queue 变更和取消要求对应 live 状态仍然存在;模型、重命名和 prompt 命令可以显式恢复普通 Session。只有 create 和 fork 会创建新 Agent。该服务把同一套感知 preset 的恢复策略和 subagent ownership fence 同时用于自身方法,以及其他 Remote namespace 使用的 Typert Agent 与 Session lookup。 + +Client adapter 提供 `SessionEventStream`,即绑定到一个普通 Session 或 direct subagent address 的 Gateway `RemoteJournalStream`。它在读取首个 page 前打开 follow,只发布连续的 `replace`、`prepend` 和 `append` 变更,并通过 tail page 修复重连或 seq 缺口。普通 record 覆盖 `[event.seq, event.seq]`,packed row 覆盖 `[event.seq, event.seq + memberCount - 1]`。业务、persistence 或无法恢复的连续性错误会终止 stream,只有物理载体断开才触发自动恢复。`SessionControlStream` 是 Gateway `RemoteSnapshotStream`;每代都以完整的进程本地 baseline 开始,因此重连会替换 queue、jobs 和 projection 状态,而不会把瞬态值当作 durable event。 + +----- + + +## 配置 + +| 字段 | 默认值 | 含义 | +|---|---:|---| +| `coldBlankProbeMaxBytes` | `1,024` | 可进行空白状态验证的冷 Session 工件最大物理大小;`0` 禁用探测 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-api-session-controller)是所有受支持字段及其 JSDoc 的完整来源。 + +----- + + +## 模型体验 + +无,因为被调用的 Agent 命令拥有任何模型可见效果。 + +#### KV Cache 影响 + +无直接影响;模型请求仍由 Agent 和 LLM 包拥有。 + +## 已知限制与延期工作 + + + +- Control baseline 表示进程本地状态,因此 Host 重启后无法重建 jobs。 +- follow 恢复失败会对调用方可见,而不会无限重试。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/session-controller/package.json b/packages/api/session-controller/package.json new file mode 100644 index 0000000000..dabfd8fd6a --- /dev/null +++ b/packages/api/session-controller/package.json @@ -0,0 +1,137 @@ +{ + "name": "@deepseek-ai/dsh-api-session-controller", + "description": "Session Remote commands, cold reads, and live control transport", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/session-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./remote-events": { + "types": "./lib/types/remote-events.d.ts", + "default": "./lib/types/remote-events.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + }, + "peerDependenciesMeta": { + "@deepseek-ai/dsh-jobs": { "optional": true }, + "@deepseek-ai/dsh-session-persistence": { "optional": true }, + "@deepseek-ai/dsh-session-projection-cache": { "optional": true } + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-jobs": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-permission-presets": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/dsh-util-crypto": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^", + "@deepseek-ai/dsh-util-workspace-path": "workspace:^" + } +} diff --git a/packages/api/session-controller/src/agent.ts b/packages/api/session-controller/src/agent.ts new file mode 100644 index 0000000000..f96c66b464 --- /dev/null +++ b/packages/api/session-controller/src/agent.ts @@ -0,0 +1,533 @@ +/** Agent activation, composition, and model-selection policy owned by API Session. */ + +import { mkdir } from 'node:fs/promises' +import type { Context } from '@deepseek-ai/cordis' +import { installModelSelection } from '@deepseek-ai/dsh-agent' +import type { + Agent, AgentOptions, AgentSetup, ModelSelection as AgentModelSelection, ModelSelectionRef, +} from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent-default-model' +import type {} from '@deepseek-ai/dsh-agent-presets' +import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import type {} from '@deepseek-ai/dsh-typert-registry' +import type { ModelSelection, SessionError } from './types.ts' + +/** Cold Session identity absent from persistence. */ +export class ApiSessionNotFound extends Error {} + +/** Session identity whose lifecycle belongs to subagent routing. */ +export class ApiSessionSubagentOwnership extends Error { + /** @param sessionId - identity reserved to subagent routing. */ + constructor(readonly sessionId: SessionId) { + super(`session "${sessionId}" is a subagent session; use subagent delivery`) + } +} + +/** Explicit-id creation attempted to adopt a Session under another cwd. */ +export class ApiSessionCwdConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedCwd: string, + readonly existingCwd: string | undefined, + ) { + super( + existingCwd === undefined + ? `session "${sessionId}" records no cwd and cannot be adopted for "${requestedCwd}"` + : `session "${sessionId}" belongs to "${existingCwd}", not "${requestedCwd}"`, + ) + } +} + +/** Explicit-id creation attempted to adopt a Session under another preset. */ +export class ApiSessionPresetConflict extends Error { + constructor( + readonly sessionId: SessionId, + readonly requestedPreset: string, + readonly existingPreset: string | undefined, + ) { + super( + existingPreset === undefined + ? `session "${sessionId}" records no agent preset and cannot be adopted under "${requestedPreset}"` + : `session "${sessionId}" runs agent preset "${existingPreset}", not "${requestedPreset}"`, + ) + } +} + +/** Failures produced while resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentError = Extract< + SessionError, + { readonly code: 'session-not-found' | 'agent-busy' | 'internal' } +> + +/** Result of resolving one ordinary Session identity to its live Agent. */ +export type ApiSessionAgentResult = + | { readonly agent: Agent } + | { readonly error: ApiSessionAgentError } + +type InstalledSelection = ModelSelectionRef & { + current: AgentModelSelection + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean +} + +/** + * Test whether generic Session routing must leave an identity to subagent routing. + * @param ctx - Host context carrying the Agent ownership registry. + * @param session - attached or live Session whose ownership is tested. + * @param agent - live Agent when one exists for the Session. + * @returns whether subagent routing owns the Session identity. + */ +export function hasApiSessionSubagentOwner( + ctx: Context, + session: Pick, + agent: Agent | undefined, +): boolean { + if (session.header.origin === 'subagent') return true + const parentId = session.header.parentSession + if (parentId === undefined || agent === undefined) return false + const parent = ctx.agents.get(parentId) + return parent !== undefined && ctx.agents.isOwnedBy(agent.id, parent) +} + +/** + * Build the stable caller-facing subagent ownership rejection. + * @param sessionId - Session identity owned by subagent routing. + * @returns a stable Session-domain failure. + */ +export function apiSessionSubagentOwnershipError(sessionId: SessionId): ApiSessionAgentError { + return { + code: 'agent-busy', + message: `session "${sessionId}" is owned by subagent routing`, + details: { reason: 'use subagent delivery for this child session' }, + } +} + +/** + * Inspect one cold Session without repairing, resuming, or publishing it. + * @param ctx - Host context carrying Session persistence. + * @param sessionId - durable Session identity. + * @param signal - optional cancellation for persistence reads. + * @returns the persisted header and complete event prefix. + */ +export async function inspectApiSession( + ctx: Context, + sessionId: SessionId, + signal?: AbortSignal, +): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + try { + using observation = await ctx.sessionQuery.observeSession(sessionId, { + ...(signal === undefined ? {} : { signal }), + projectionMode: 'none', + }) + if (observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + return { meta: observation.header, events: [...observation.events] } + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + throw error + } +} + +/** Owns every operation that may create, resume, or configure a Web Agent. */ +export class ApiSessionAgentController { + private readonly resumes = new Map>() + private readonly creations = new Map>() + private readonly selections = new WeakMap() + private readonly imageAdmissionChains = new WeakMap>() + + /** @param ctx - Host context carrying Agent, model, persistence, and Typert services. */ + constructor(private readonly ctx: Context) { + ctx.typert.lookups.configure('agent', async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId) + if ('error' in found) throw new TypertLookupFailure(found.error) + return found.agent + }) + ctx.typert.lookups.configure('session', async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId) + if ('error' in found) throw new TypertLookupFailure(found.error) + return found.agent.session + }) + ctx.typert.contexts.configureHost('agent', async (sessionId: SessionId) => { + const found = await this.resolveAgent(sessionId) + if ('error' in found) throw new TypertLookupFailure(found.error) + return found.agent.ctx + }) + } + + /** + * Resolve or resume one ordinary Session, deduplicating concurrent resumes. + * @param sessionId - ordinary Session identity. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveAgent(sessionId: SessionId): Promise { + return this.resolve(sessionId) + } + + /** + * Resolve one ordinary Session from an already-retained exact observation. + * @param observation - Host-owned observation whose preparation stays pinned through setup. + * @returns the live Agent or a stable Session-domain failure. + */ + async resolveObservedAgent(observation: SessionObservation): Promise { + return this.resolve(observation.header.id, observation) + } + + private async resolve( + sessionId: SessionId, + observation?: SessionObservation, + ): Promise { + const live = this.liveAgent(sessionId) + if (live !== undefined) return live + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + return { error: apiSessionSubagentOwnershipError(sessionId) } + } + + let resume = this.resumes.get(sessionId) + if (resume === undefined) { + resume = this.resume(sessionId, observation).finally(() => { this.resumes.delete(sessionId) }) + this.resumes.set(sessionId, resume) + } + try { + return { agent: await resume } + } catch (error: unknown) { + if (error instanceof ApiSessionNotFound) { + return { + error: { + code: 'session-not-found', + message: error.message, + details: { sessionId }, + }, + } + } + if (error instanceof ApiSessionSubagentOwnership) { + return { error: apiSessionSubagentOwnershipError(error.sessionId) } + } + const raced = this.liveAgent(sessionId) + if (raced !== undefined) return raced + const racedSession = this.ctx.sessions.get(sessionId) + if (racedSession !== undefined && hasApiSessionSubagentOwner(this.ctx, racedSession, undefined)) { + return { error: apiSessionSubagentOwnershipError(sessionId) } + } + return { + error: { + code: 'internal', + message: `resume failed for session "${sessionId}": ${String(error)}`, + details: {}, + }, + } + } + } + + /** + * Resolve one requested identity, creating or resuming it once. + * @param sessionId - requested Session identity. + * @param cwd - directory the Session must own. + * @param checkPersistedIdentity - whether to inspect a cold identity before creation. + * @param presetId - optional Agent preset the Session must own. + * @returns the matching live ordinary Agent. + */ + async ensureSession( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId?: string, + ): Promise { + let creation = this.creations.get(sessionId) + if (creation === undefined) { + creation = this.createOrAdopt(sessionId, cwd, checkPersistedIdentity, presetId) + .catch((error: unknown) => { + const live = this.ctx.agents.get(sessionId) + if (live !== undefined) { + if (hasApiSessionSubagentOwner(this.ctx, live.session, live)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + return live + } + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + throw error + }) + .finally(() => { this.creations.delete(sessionId) }) + this.creations.set(sessionId, creation) + } + const agent = await creation + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (presetId !== undefined) { + this.assertPresetUnchanged(sessionId, presetId, this.presetForSession(agent.session)) + } + if (agent.session.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, agent.session.header.cwd) + } + return agent + } + + /** + * Install or return the Session-local model selection used by prompt assembly. + * @param agent - live Agent that owns the selection. + * @returns the installed mutable selection reference. + */ + selectionFor(agent: Agent): InstalledSelection { + const installed = this.selections.get(agent) + if (installed !== undefined) return installed + const projectionState = this.ctx.sessionProjections.stateOf(agent.session, 'modelSelection') + if (projectionState === undefined) { + throw new Error('api-session: required modelSelection projection is not registered') + } + let picked = projectionState.pending === null + ? undefined + : agentModelSelection(projectionState.pending) + const defaultModel = this.ctx.agentDefaultModel + const selection: InstalledSelection = { + get current(): AgentModelSelection { + if (picked !== undefined) return picked + const loggedHeader = agent.session.requestHeader() + if (loggedHeader === undefined) return defaultModel.currentSelection() + const logged = loggedHeader.config + return { + provider: logged.provider, + model: logged.model, + // An effort the adapter defaulted is not a conversation choice: restoring + // it as one would make an unchanged default read as a request change. + ...(logged.reasoningEffort === undefined + || loggedHeader.adapterDefaults?.reasoningEffort === true + ? {} + : { reasoningEffort: logged.reasoningEffort }), + } + }, + set current(next: AgentModelSelection) { + picked = next + }, + consume(provider: string, model: string, reasoningEffort: string | undefined): boolean { + if (picked?.provider !== provider + || picked.model !== model + || picked.reasoningEffort !== reasoningEffort) return false + picked = undefined + return true + }, + assembled: undefined, + } + installModelSelection(agent.ctx, selection) + this.selections.set(agent, selection) + return selection + } + + /** + * Commit and cache one validated selection for the next prompt assembly. + * @param agent - live Agent that owns the selection. + * @param selection - validated selection to record and apply. + */ + selectForNextRequest(agent: Agent, selection: AgentModelSelection): void { + agent.session.append('model/selection', selection) + this.selectionFor(agent).current = selection + } + + /** + * Let a matching durable request header retire the execution cache. + * @param agent - live Agent whose request was recorded. + * @param provider - provider route used by the request. + * @param model - provider-owned model used by the request. + * @param reasoningEffort - adapter-owned effort used by the request. + * @returns whether the pending selection was consumed. + */ + consumeSelection( + agent: Agent, + provider: string, + model: string, + reasoningEffort: string | undefined, + ): boolean { + return this.selections.get(agent)?.consume(provider, model, reasoningEffort) ?? false + } + + /** + * Read the current Agent preset from the Session projection. + * @param session - live Session whose projection state is available. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForSession(session: Session): string | undefined { + return this.ctx.sessionProjections.stateOf(session, 'agentPreset') ?? undefined + } + + /** + * Serialize image admission and model selection for one Agent. + * @param agent - live Agent that owns the serialization chain. + * @param operation - asynchronous operation admitted after prior work settles. + * @returns the operation result or rejection. + */ + serializeImageAdmission(agent: Agent, operation: () => Promise): Promise { + const result = (this.imageAdmissionChains.get(agent) ?? Promise.resolve()).then(operation) + this.imageAdmissionChains.set(agent, result.then(() => undefined, () => undefined)) + return result + } + + /** + * Resolve the preset id and pre-publication Agent setup for a create or resume. + * @param presetId - requested preset or the configured default when omitted. + * @returns the resolved preset identity and Agent setup callback. + */ + async composeAgent(presetId: string | undefined): Promise<{ + readonly agentPreset?: string + readonly setup: AgentSetup + }> { + const presets = this.ctx.get('agentPresets') + if (presets === undefined) return { setup: (agentCtx) => { this.installSelection(agentCtx) } } + const resolvedId = (await presets.resolve(presetId)).id + return { + agentPreset: resolvedId, + setup: async (agentCtx) => { + this.installSelection(agentCtx) + await presets.mount(agentCtx, resolvedId) + }, + } + } + + private liveAgent(sessionId: SessionId): ApiSessionAgentResult | undefined { + const agent = this.ctx.agents.get(sessionId) + if (agent === undefined) return undefined + return hasApiSessionSubagentOwner(this.ctx, agent.session, agent) + ? { error: apiSessionSubagentOwnershipError(sessionId) } + : { agent } + } + + private async resume(sessionId: SessionId, supplied?: SessionObservation): Promise { + if (supplied !== undefined) return this.resumeObserved(sessionId, supplied) + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId) + return await this.resumeObserved(sessionId, observation) + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + throw error + } + } + + private async resumeObserved( + sessionId: SessionId, + observation: SessionObservation, + ): Promise { + if (observation.header.id !== sessionId || observation.header.cwd === undefined) { + throw new ApiSessionNotFound(`session "${sessionId}" not found`) + } + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + const composition = await this.composeAgent(this.presetForObservation(observation)) + const published = this.ctx.sessions.get(sessionId) + const live = this.ctx.agents.get(sessionId) + if (published !== undefined && hasApiSessionSubagentOwner(this.ctx, published, live)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + })).agent + } + + private async createOrAdopt( + sessionId: SessionId, + cwd: string, + checkPersistedIdentity: boolean, + presetId: string | undefined, + ): Promise { + const attached = this.ctx.sessions.get(sessionId) + const live = this.ctx.agents.get(sessionId) + if (attached !== undefined && hasApiSessionSubagentOwner(this.ctx, attached, live)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (live !== undefined) return live + + if (checkPersistedIdentity) { + try { + using observation = await this.ctx.sessionQuery.observeSession(sessionId) + if (hasApiSessionSubagentOwner(this.ctx, { header: observation.header }, undefined)) { + throw new ApiSessionSubagentOwnership(sessionId) + } + if (observation.header.cwd !== cwd) { + throw new ApiSessionCwdConflict(sessionId, cwd, observation.header.cwd) + } + const storedPreset = this.presetForObservation(observation) + this.assertPresetUnchanged(sessionId, presetId, storedPreset) + const composition = await this.composeAgent(storedPreset) + return (await this.ctx.agents.resume({ + resumeSessionId: sessionId, + agentOptions: this.agentOptions(), + setup: composition.setup, + })).agent + } catch (error: unknown) { + if (!(error instanceof SessionQueryError) + || error.code !== 'SESSION_QUERY_SESSION_NOT_FOUND') throw error + } + } + + try { + await mkdir(cwd, { recursive: true }) + } catch (error: unknown) { + throw new Error(`failed to ensure project directory "${cwd}": ${String(error)}`, { cause: error }) + } + const composition = await this.composeAgent(presetId) + return (await this.ctx.agents.create({ + sessionId, + agentOptions: this.agentOptions(), + meta: { + cwd, + ...(composition.agentPreset === undefined ? {} : { agentPreset: composition.agentPreset }), + }, + setup: composition.setup, + })).agent + } + + private agentOptions(): AgentOptions { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection() + return { provider, model } + } + + private installSelection(agentCtx: Context): void { + const agent = agentCtx.agent + if (agent === undefined) throw new Error('api-session: Agent setup has no scoped Agent') + this.selectionFor(agent) + } + + /** + * Read the current Agent preset from an all-projections observation. + * @param observation - exact Session observation carrying its projection snapshot. + * @returns the current preset, or undefined when the capability is absent. + */ + presetForObservation(observation: SessionObservation): string | undefined { + if (observation.projections === undefined) { + throw new Error('api-session: Agent activation requires a projected Session observation') + } + return observation.projections.values.agentPreset ?? undefined + } + + private assertPresetUnchanged( + sessionId: SessionId, + requested: string | undefined, + existing: string | undefined, + ): void { + if (requested === undefined || requested === existing) return + throw new ApiSessionPresetConflict(sessionId, requested, existing) + } +} + +function agentModelSelection(selection: ModelSelection): AgentModelSelection { + return { + provider: selection.provider, + model: selection.model, + ...(selection.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(selection.reasoningEffort) }), + } +} diff --git a/packages/api/session-controller/src/catalog.ts b/packages/api/session-controller/src/catalog.ts new file mode 100644 index 0000000000..0c97107f03 --- /dev/null +++ b/packages/api/session-controller/src/catalog.ts @@ -0,0 +1,67 @@ +/** Shared projection of the live LLM registry into the browser model catalog. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { + ModelCatalog, + ModelReasoning, + ModelSelection, +} from './types.ts' + +/** + * Build the browser model catalog without requiring a Session. + * @param ctx - Host context carrying the live LLM registry. + * @param defaultSelection - deployment default used before a Session selects a model. + * @returns successful non-empty provider groups and isolated provider failures. + */ +export async function buildModelCatalog( + ctx: Context, + defaultSelection: ModelSelection = ctx.agentDefaultModel.currentSelection(), +): Promise { + const providers = ctx.llm.listProviders() + const catalog = await Promise.all(providers.map(async (provider) => { + try { + const models = await ctx.llm.listModels(provider.id) + const entries = await Promise.all(models.map(async (model) => { + const resolved = await ctx.llm.resolveModelInfo(provider.id, model.id) + const reasoning: ModelReasoning | undefined = resolved.reasoning === undefined + ? undefined + : { + efforts: resolved.reasoning.efforts.map(effort => ({ + id: effort.id, + name: effort.name, + ...(effort.description === undefined ? {} : { description: effort.description }), + })), + ...(resolved.reasoning.defaultEffort === undefined + ? {} + : { defaultEffort: resolved.reasoning.defaultEffort }), + } + return { + id: model.id, + name: model.name, + ...(model.description === undefined ? {} : { description: model.description }), + ...(reasoning === undefined ? {} : { reasoning }), + } + })) + return { + kind: 'group' as const, + group: { id: provider.id, name: provider.name, models: entries }, + } + } catch (error) { + return { + kind: 'failure' as const, + failure: { + id: provider.id, + name: provider.name, + message: error instanceof Error ? error.message : String(error), + }, + } + } + })) + return { + default: { ...defaultSelection }, + routableProviders: providers.map(provider => provider.id), + groups: catalog.flatMap(item => item.kind === 'group' ? [item.group] : []) + .filter(group => group.models.length > 0), + failures: catalog.flatMap(item => item.kind === 'failure' ? [item.failure] : []), + } +} diff --git a/packages/api/session-controller/src/client/contract/events.ts b/packages/api/session-controller/src/client/contract/events.ts new file mode 100644 index 0000000000..39ce09ccab --- /dev/null +++ b/packages/api/session-controller/src/client/contract/events.ts @@ -0,0 +1,158 @@ +/** Observable contiguous Session event window consumed by domain assemblers. */ +import { notifySubscribers, type ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { ChunkRowEvent } from '../../types.ts' + +/** Standard Session event or compact historical Assistant run. */ +export type SessionEventLike = SessionEvent | ChunkRowEvent + +/** Client history entry retaining its coarse transport discriminator. */ +export type SessionEventLikeEntry = + | { readonly type: 'event'; readonly event: SessionEvent } + | { readonly type: 'chunks'; readonly event: ChunkRowEvent } + +/** Scalar live entry accepted by append-only Client paths. */ +export type SessionLiveEventEntry = Extract + +interface EventWindowLeaf { + readonly kind: 'leaf' + readonly entries: readonly SessionEventLikeEntry[] + readonly length: number +} + +interface EventWindowConcat { + readonly kind: 'concat' + readonly left: EventWindowNode + readonly right: EventWindowNode + readonly length: number +} + +type EventWindowNode = EventWindowLeaf | EventWindowConcat + +function leaf(entries: readonly SessionEventLikeEntry[]): EventWindowLeaf { + return { kind: 'leaf', entries, length: entries.length } +} + +function concat(left: EventWindowNode, right: EventWindowNode): EventWindowConcat { + return { kind: 'concat', left, right, length: left.length + right.length } +} + +function materialize(node: EventWindowNode): readonly SessionEventLikeEntry[] { + if (node.kind === 'leaf') return node.entries + const entries = new Array(node.length) + const pending: EventWindowNode[] = [node] + let index = 0 + while (pending.length > 0) { + const current = pending.pop() as EventWindowNode + if (current.kind === 'concat') { + pending.push(current.right, current.left) + continue + } + for (const entry of current.entries) { + entries[index] = entry + index += 1 + } + } + return entries +} + +function windowSnapshot( + node: EventWindowNode, + hasMore: boolean, + revision: number, + change: SessionEventChange, +): SessionEventWindow { + let entries: readonly SessionEventLikeEntry[] | undefined + return { + get entries() { + entries ??= materialize(node) + return entries + }, + hasMore, + revision, + change, + } +} + +/** Exact delta that produced the latest event-window revision. */ +export type SessionEventChange = + | { readonly kind: 'replace'; readonly entries: readonly SessionEventLikeEntry[] } + | { readonly kind: 'prepend'; readonly entries: readonly SessionEventLikeEntry[] } + | { readonly kind: 'append'; readonly entries: readonly SessionLiveEventEntry[] } + +/** Current contiguous event window and its latest synchronous delta. */ +export interface SessionEventWindow { + readonly entries: readonly SessionEventLikeEntry[] + readonly hasMore: boolean + readonly revision: number + readonly change: SessionEventChange +} + +/** Conversation-facing event source exposed by one Session binding. */ +export type SessionEventSource = ObservableSnapshot + +/** Session-owned event feed; every accepted window mutation publishes synchronously. */ +export class MutableSessionEventSource implements SessionEventSource { + private readonly listeners = new Set<() => void>() + private window: EventWindowNode = leaf([]) + private snapshot: SessionEventWindow = windowSnapshot( + this.window, + false, + 0, + { kind: 'replace', entries: [] }, + ) + + /** @returns the cached event-window snapshot. */ + getSnapshot(): SessionEventWindow { return this.snapshot } + + /** + * Subscribe to synchronous window publication. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Replace the complete contiguous window. + * @param entries - complete window. + * @param hasMore - whether older history remains. + */ + replace(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.window = leaf(entries) + this.publish(hasMore, { kind: 'replace', entries }) + } + + /** + * Prepend one older contiguous page. + * @param entries - newly loaded older entries. + * @param hasMore - whether still older history remains. + */ + prepend(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.window = concat(leaf(entries), this.window) + this.publish(hasMore, { kind: 'prepend', entries }) + } + + /** + * Append one contiguous live entry. + * @param entry - live tail entry. + */ + append(entry: SessionLiveEventEntry): void { + const entries = [entry] + this.window = concat(this.window, leaf(entries)) + this.publish(this.snapshot.hasMore, { + kind: 'append', + entries, + }) + } + + private publish( + hasMore: boolean, + change: SessionEventChange, + ): void { + this.snapshot = windowSnapshot(this.window, hasMore, this.snapshot.revision + 1, change) + notifySubscribers(this.listeners, '[session-controller] event feed') + } +} diff --git a/packages/api/session-controller/src/client/contract/result.ts b/packages/api/session-controller/src/client/contract/result.ts new file mode 100644 index 0000000000..5306e611de --- /dev/null +++ b/packages/api/session-controller/src/client/contract/result.ts @@ -0,0 +1,29 @@ +/** Client operation results spanning the Session and subagent Remote calls. */ + +import type { RpcError } from '@deepseek-ai/dsh-client-connection/client' +import type { SubagentControlError } from '@deepseek-ai/dsh-subagent/client' +import type { SessionError } from '../../types.ts' + +/** Failure surfaced by the Client Session object layer. */ +export type ClientFailure = RpcError | SessionError | SubagentControlError + +/** Success or failure returned by a Client Session operation. */ +export type ClientResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ClientFailure } + +/** + * Fold a rejected carrier operation into the Client Session failure vocabulary. + * @param error - rejection from a Remote or local carrier call. + * @returns the failure branch of a Client Session result. + */ +export function transportResult(error: unknown): ClientResult { + return { + ok: false, + error: { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + } +} diff --git a/packages/api/session-controller/src/client/contract/session.ts b/packages/api/session-controller/src/client/contract/session.ts new file mode 100644 index 0000000000..6ac4182bb9 --- /dev/null +++ b/packages/api/session-controller/src/client/contract/session.ts @@ -0,0 +1,94 @@ +/** + * The outward session face. Feature packages never see the concrete Session + * class: components read lifecycle state through `useSession` (the + * ObservableSnapshot half), and orchestration code calls the behavior verbs + * below — nothing else. Widening this interface is the explicit act of + * widening what features may do to a session (and what every test fixture + * must stub); implementation-internal entry points (history staging, wire-frame + * dispatch) stay on the class, invisible out here. + */ +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' +import type { PromptContentPart, QueueAction } from '../../types.ts' +import type { ClientResult } from './result.ts' +import type { SessionSnapshot } from './snapshot.ts' + +/** Key-addressed projection read face (the useProjection resolution path; see ProjectionValueStore). */ +export interface ProjectionsFace { + /** + * The identity-stable bare observable for one projection key (absence is + * an `undefined` snapshot, never a missing face). + * @param key - projection key. + * @returns the key's value face. + */ + faceOf(key: string): ObservableSnapshot +} + +/** Identity plus the behavior verbs features may invoke on a session. */ +export interface ISession { + /** The session's host identity (agent id — same axis). */ + readonly sessionId: SessionId + /** Host-computed projection values by key (the useProjection seat). */ + readonly projections: ProjectionsFace + /** + * Send a prompt into the session. + * @param content - text plus browser-owned temporary image uploads. + * @param mode - 'queue' appends a turn; 'steer' interrupts the running one. + * @returns acceptance, or the business error (also mirrored into snapshot.promptError). + */ + prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> + /** + * Resolve one durable image referenced by this session. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + readAttachment( + attachmentId: AttachmentIdType, + ): Promise> + /** + * Apply one edit, remove, or strict steer action to a still-pending queue occurrence. + * @param itemId - agent-owned inbox occurrence identity. + * @param action - requested queue operation. + * @returns acceptance, or a business/transport error. + */ + updateQueue(itemId: MessageId, action: QueueAction): Promise> + /** + * Cancel the running turn. Pending queued work remains and resumes in FIFO + * order after the Host reaches cancellation quiescence. + * @returns acceptance, or the business error. + */ + cancel(): Promise> + /** + * Rename this session (explicit user title; pins it against automatic + * regeneration). + * @param title - raw title text (the host normalizes acceptance). + * @returns the normalized accepted title and its event seq, or the business error. + */ + rename(title: string): Promise> + /** + * Extend the history window backwards (older messages pagination). + * @returns completion; failures land in snapshot.openState/loadingOlder. + */ + loadOlder(): Promise + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the Remote face's error branch. + */ + command(line: string): Promise> +} + +/** + * The full outward face: behavior verbs plus the Session lifecycle read side + * (the `useSession` hook source). This is the type carried by + * `SessionBinding.session` and the provide channel. + */ +export type SessionFace = ISession & ObservableSnapshot diff --git a/packages/api/session-controller/src/client/contract/sessions.ts b/packages/api/session-controller/src/client/contract/sessions.ts new file mode 100644 index 0000000000..7c8a90e662 --- /dev/null +++ b/packages/api/session-controller/src/client/contract/sessions.ts @@ -0,0 +1,123 @@ +/** + * The outward sessions-service face — what `ctx.sessions` exposes to feature + * packages. Transport entry points and implementation internals stay on + * the concrete class. Widening this interface is the + * explicit act of widening what features may do to the sessions domain. + */ +import type { Context } from '@deepseek-ai/cordis' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { AgentContext } from '../scope.ts' +import type { SessionSearchResultItem } from '../sessions/manager.ts' +import type { SessionBinding, SessionListState } from '../sessions/service.ts' +import type { ClientResult } from './result.ts' +import type { SessionFace } from './session.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' + +export type { AgentContext } from '../scope.ts' + +/** The sessions-service face injected as `ctx.sessions`. */ +export interface ISessions { + /** The useSessions standard feed (list rows + current selection; read face — writes stay inside the domain). */ + readonly list: ObservableSnapshot + /** + * The `session.search` result bound the wire schema fixes, exposed to + * presentation as injected data. Not per-connection state: every transport + * (fixture included) reports the same number. + */ + readonly searchResultLimit: number + /** + * Create or adopt a Session on the Host. + * @param opts - target workspace, directory, and optional preallocated identity. + * @returns the Session identity after its local binding is addressable. + */ + create(opts?: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + }): Promise + /** + * Select a session as current. + * @param id - session id (must exist in the list; unknown ids fail loud). + */ + open(id: SessionId): void + /** + * Open a healthy catalog child through its exact direct-parent address. + * @param address - catalog-derived parent and child ids. + */ + openSubagent(address: SubagentAddress): void + /** + * Resolve an already discovered direct-parent address without opening it. + * @param id - possible addressed child id. + * @returns the retained address, when present. + */ + subagentAddress(id: SessionId): SubagentAddress | undefined + /** + * Mark whether a catalog menu is consuming live membership updates. + * @param parentSessionId - catalog owner. + * @param open - current menu state. + */ + setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void + /** + * Refresh one direct-child catalog. + * @param parentSessionId - catalog owner. + * @returns completion of the current or newly started refresh. + */ + refreshSubagents(parentSessionId: SessionId): Promise + + /** Clear the current selection into the no-session view state. */ + clear(): void + /** + * Refresh the Host-authoritative Session list. + * @returns completion of the current or newly started Session-list refresh. + */ + refresh(): Promise + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results, or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> + /** + * Fork a session from a completed-turn prefix of the source; on resolution + * the child is in the list store and `open()` can target it. + * @param opts - source session id, the optional event seq anchoring the + * cut (the boundary is the first turn/end at or after it; an in-log + * anchor in an open turn is unavailable rather than clipped backward), + * and whether to increment an inherited durable title before resolving. + * @returns the child session id. + * @throws when the fork fails, or when a requested child-title rename fails after creation. + */ + fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise + /** + * Resolve an Agent-scoped context view (use-and-discard). + * @param id - session id. + * @returns scoped ctx, or undefined for a session neither listed nor already scoped. + */ + scope(id: SessionId): AgentContext | undefined + /** + * Read the Agent scope tag off a context (service-method boundary: fetch + * bundles must reach scope resolution through ctx.sessions). + * @param ctx - any client context. + * @returns the session id, or undefined on root contexts. + */ + scopeOf(ctx: Context): SessionId | undefined + /** + * Resolve the session face behind an Agent-scoped context. + * @param ctx - an Agent-scoped context. + * @returns the session face, or undefined when the ctx is untagged or its scope was pruned. + */ + sessionOf(ctx: Context): SessionFace | undefined + /** + * Resolve the stable session binding (scope-addressed assembly feed). + * @param id - session id. + * @returns binding, or undefined for a session neither listed nor already scoped. + */ + binding(id: SessionId): SessionBinding | undefined +} diff --git a/packages/api/session-controller/src/client/contract/snapshot.ts b/packages/api/session-controller/src/client/contract/snapshot.ts new file mode 100644 index 0000000000..3f5f4ebd1c --- /dev/null +++ b/packages/api/session-controller/src/client/contract/snapshot.ts @@ -0,0 +1,49 @@ +/** Session-owned observable state excluding Conversation target data. */ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { ClientFailure } from './result.ts' + +/** One transient inbox occurrence from the authoritative queue snapshot. */ +export interface QueuedMessage { + readonly id: MessageId + readonly messageId: MessageId + readonly placement: 'queued' | 'steering' | 'context' + readonly content: readonly ContentBlock[] + readonly preview: string + readonly text: string | null +} + +/** History-open lifecycle of a Session event window. */ +export type OpenState = 'cold' | 'loading' | 'open' | 'error' + +/** Send/stop failure surfaced by Session consumers. */ +export interface PromptError { + readonly op: 'send' | 'stop' + readonly error: ClientFailure +} + +/** Immutable Session lifecycle and control snapshot. */ +export interface SessionSnapshot { + readonly sessionId: SessionId + readonly queue: readonly QueuedMessage[] + readonly running: boolean + readonly subagent: { + readonly address: SubagentAddress + /** Absent until the direct-parent catalog resolves. */ + readonly parentAvailable?: boolean + } | null + readonly removed: boolean + readonly openState: OpenState + readonly openError: ClientFailure | null + readonly hasMore: boolean + readonly loadingOlder: boolean + readonly promptError: PromptError | null + readonly blank: boolean + readonly lastAgentError: string | null + /** A prompt call has begun on this Client Session object. */ + readonly promptAttempted: boolean + /** The first accepted prompt has not reached a durable `turn/start` event. */ + readonly awaitingFirstTurn: boolean +} diff --git a/packages/api/session-controller/src/client/index.ts b/packages/api/session-controller/src/client/index.ts new file mode 100644 index 0000000000..10a10261bf --- /dev/null +++ b/packages/api/session-controller/src/client/index.ts @@ -0,0 +1,111 @@ +/** Client Session object layer, Agent scopes, and Remote lifecycle wiring. */ + +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent/types' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { createSessionControlStream } from './transport.ts' +import { ClientSessions } from './sessions/service.ts' +import type { SessionRemotes } from './sessions/remotes.ts' +import type {} from '../remote-events.ts' + +export { + createSessionControlStream, + SessionEventStream, + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, + sessionStreamFailure, +} from './transport.ts' +export type { + ClientSessionPageRequest, + SessionControlStream, + SessionControlStreamOptions, + SessionEventStreamOptions, + SessionJournalChange, + SessionRemote, +} from './transport.ts' +export { createScope, scopeOf } from './scope.ts' +export type { AgentContext, AgentScopeHandle } from './scope.ts' +export { SessionCreateError, SessionForkError } from './sessions/service.ts' +export type { SessionBinding, SessionListState, SessionSummary } from './sessions/service.ts' +export type { + SessionListPhase, + SessionListSnapshot, + SessionSearchResultItem, + SubagentCatalogSnapshot, +} from './sessions/manager.ts' +export type { Session } from './sessions/session.ts' +export type { + ProjectionsBaseline, + ProjectionValueStore, + SessionProjectionMap, + UseProjection, +} from './sessions/projection-store.ts' +export type { ISession, ProjectionsFace, SessionFace } from './contract/session.ts' +export type { ISessions } from './contract/sessions.ts' +export { MutableSessionEventSource } from './contract/events.ts' +export type { + SessionEventChange, + SessionEventLike, + SessionEventLikeEntry, + SessionEventSource, + SessionEventWindow, + SessionLiveEventEntry, +} from './contract/events.ts' +export type { + OpenState, + PromptError, + QueuedMessage, + SessionSnapshot, +} from './contract/snapshot.ts' +export type { ClientFailure, ClientResult } from './contract/result.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Client Session object layer and Agent scope owner. */ + sessions: import('./contract/sessions.ts').ISessions + } +} + +/** Required wire, Remote, and Context projection services. */ +export const inject = [ + 'connection', + 'typert', + 'remote', + 'remote.commands', + 'remote.session', + 'remote.subagents', +] + +/** + * Install Client Session state and its reconnecting control stream. + * @param ctx - Client Cordis context. + */ +export function apply(ctx: Context): void { + const connection = ctx.get('connection') as ConnectionHandle + const remotes = ctx.remote as unknown as SessionRemotes + const sessions = new ClientSessions(ctx, remotes) + ctx.remote.$on('api-session/added', (summary) => { sessions.handleSessionAdded(summary) }) + ctx.remote.$on('api-session/removed', (sessionId) => { sessions.handleSessionRemoved(sessionId) }) + ctx.remote.$on('api-session/status', (sessionId, running) => { + sessions.handleSessionStatus(sessionId, running) + }) + ctx.remote.$on('api-session/activity', (sessionId, updatedAt) => { + sessions.handleSessionActivity(sessionId, updatedAt) + }) + ctx.remote.$on('api-session/error', (sessionId, message) => { + sessions.handleSessionError(sessionId, message) + }) + + const control = createSessionControlStream(remotes, { + accept: (frame) => { sessions.handleControlFrame(frame) }, + failed: (error) => { console.error('[session-controller] control stream failed:', error) }, + }) + control.start() + ctx.on('connection/reset', () => { sessions.handleConnected() }) + if (connection.hostDescription.getSnapshot() !== undefined) sessions.handleConnected() + ctx.typert.contexts.registerClient('agent', { + identity: candidate => sessions.scopeOf(candidate), + resolve: sessionId => sessions.resolveAgentScope(sessionId), + }) + ctx.effect(() => async () => { await control.dispose() }, 'session-controller.client.control') +} diff --git a/packages/client/runtime/src/client/ordered-baseline.ts b/packages/api/session-controller/src/client/ordered-baseline.ts similarity index 100% rename from packages/client/runtime/src/client/ordered-baseline.ts rename to packages/api/session-controller/src/client/ordered-baseline.ts diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/api/session-controller/src/client/scope.ts similarity index 91% rename from packages/client/runtime/src/client/agents/scope.ts rename to packages/api/session-controller/src/client/scope.ts index b1078e71ca..5e1dca62a3 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/api/session-controller/src/client/scope.ts @@ -17,12 +17,13 @@ */ import { Context as CordisContext } from '@deepseek-ai/cordis' import type { Context, Fiber } from '@deepseek-ai/cordis' -import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import type { TypertClientRemote, TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { TypertRemoteScopeApi } from '@deepseek-ai/dsh-typert-protocol' /** Client Cordis Context carrying one Agent identity and its scoped Remote namespaces. */ export type AgentContext = Omit & { - readonly remote: TypertClientRemote & TypertRemoteScopeApi<'agent'> + readonly remote: ClientRemote & TypertRemoteScopeApi<'agent'> } /** Context tag written by {@link createScope}. */ diff --git a/packages/api/session-controller/src/client/sessions/history-records.ts b/packages/api/session-controller/src/client/sessions/history-records.ts new file mode 100644 index 0000000000..77ed4ad980 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/history-records.ts @@ -0,0 +1,39 @@ +/** Client range access and type narrowing for aligned Session history records. */ + +import type { + SessionHistoryRecord, +} from '../../types.ts' +import type { SessionEventLikeEntry } from '../contract/events.ts' + +/** + * Narrow aligned wire records to their Client event types without allocation. + * @param records - validated history transport records. + * @returns the same record array with typed inner events. + */ +export function historyEntries( + records: readonly SessionHistoryRecord[], +): readonly SessionEventLikeEntry[] { + return records as unknown as readonly SessionEventLikeEntry[] +} + +/** + * Read the first logical sequence represented by one wire record. + * @param record - validated scalar event or packed Assistant delta run. + * @returns inclusive first Session sequence. + */ +export function historyRecordFirstSeq(record: SessionHistoryRecord): number { + return record.event.seq +} + +/** + * Read the final logical sequence represented by one wire record. + * @param record - validated scalar event or packed Assistant delta run. + * @returns inclusive final Session sequence. + */ +export function historyRecordLastSeq(record: SessionHistoryRecord): number { + if (record.type === 'event') return record.event.seq + const length = record.event.type === 'chunkrow/tool-call-chunks' + ? record.event.data.args.length + : record.event.data.texts.length + return record.event.seq + length - 1 +} diff --git a/packages/client/runtime/src/client/sessions/lineage.ts b/packages/api/session-controller/src/client/sessions/lineage.ts similarity index 76% rename from packages/client/runtime/src/client/sessions/lineage.ts rename to packages/api/session-controller/src/client/sessions/lineage.ts index 7579310f49..09551369b6 100644 --- a/packages/client/runtime/src/client/sessions/lineage.ts +++ b/packages/api/session-controller/src/client/sessions/lineage.ts @@ -2,18 +2,18 @@ // The input order is authoritative; lineage only makes each child adjacent to its parent. // Orphaned lineage degrades to root level; cycles fail soft and emit as roots. -import type { SessionId, SessionSummary } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { PendingInteractionStatus } from './pending.ts' +import type { SessionSummary } from '../../types.ts' -/** Host list summary enriched with the latest mux-projected durable title. */ +/** Host list summary enriched with the latest Session Controller title projection. */ export interface TitledSessionSummary extends SessionSummary { title?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> } -/** One flattened session-list row with lineage depth and live pending interaction. */ +/** One flattened session-list row with lineage depth. */ export interface SessionListEntry { sessionId: SessionId title?: string @@ -25,12 +25,8 @@ export interface SessionListEntry { /** Coarse durable origin for navigation filtering; not a continuation capability. */ origin?: 'subagent' cwd?: string - /** Agent preset the session's agent was composed from (summary passthrough). */ - agentPreset?: string /** Current host-computed projection values for list consumers. */ projectionValues?: Readonly> - /** User interaction currently blocking this session, derived from live mux frames. */ - pendingInteraction?: PendingInteractionStatus /** Finished running while not selected and not yet opened — the sidebar's green "done" reminder (clears on select or the next run). */ completed: boolean /** Lineage indent depth: root = 0; the UI just multiplies by the indent width. */ @@ -42,13 +38,11 @@ export interface SessionListEntry { * follows the established input order; this projection never re-sorts a * hydrated list from mutable timestamps. * @param summaries - the host's session.list items. - * @param pendingInteractions - current manager-owned interaction status by session. * @param completed - sessions with a pending completion reminder (manager-owned live fact; absent = false). * @returns display rows in render order. */ export function flattenLineage( summaries: readonly TitledSessionSummary[], - pendingInteractions?: ReadonlyMap, completed?: ReadonlySet, ): SessionListEntry[] { const byId = new Map() @@ -70,14 +64,12 @@ export function flattenLineage( const visited = new Set() const walk = (s: TitledSessionSummary, depth: number): void => { if (visited.has(s.sessionId)) { - console.warn(`[web-runtime] lineage cycle at ${s.sessionId}; emitting as root`) + console.warn(`[session-controller] lineage cycle at ${s.sessionId}; emitting as root`) return } visited.add(s.sessionId) - const pendingInteraction = pendingInteractions?.get(s.sessionId) out.push({ ...s, - ...(pendingInteraction === undefined ? {} : { pendingInteraction }), completed: completed?.has(s.sessionId) ?? false, depth, }) diff --git a/packages/api/session-controller/src/client/sessions/manager.ts b/packages/api/session-controller/src/client/sessions/manager.ts new file mode 100644 index 0000000000..9ad6d88f96 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/manager.ts @@ -0,0 +1,1022 @@ +// SessionManager: the instance cluster Map (lazy-built, resident) + the frame +// dispatch entry + list state, constructed and held by ClientSessions (one per browser client). +// List data never enters zustand; React connects via subscribe/getListSnapshot. + +import type { SubagentAddress, SubagentCatalog } from '@deepseek-ai/dsh-subagent/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { + SessionControlBaseline, + SessionControlFrame, + SessionQueuedItem, + SessionError, + SessionSummary, + SessionJob as JobView, +} from '../../types.ts' +import { mergeOrderedBaseline } from '../ordered-baseline.ts' +import type { ClientFailure, ClientResult } from '../contract/result.ts' +import { transportResult } from '../contract/result.ts' +import type { SessionListEntry, TitledSessionSummary } from './lineage.ts' +import { flattenLineage } from './lineage.ts' +// Type-only merge edge: the title domain's client-namespace outlet declares +// the 'title' projection key this manager projects into list rows (and any +// useProjection('title') consumer reads). Zero value imports by construction. +import type {} from '@deepseek-ai/dsh-session-title/client' +import { Notifier } from './notifier.ts' +import { ProjectionValueStore } from './projection-store.ts' +import { Session } from './session.ts' +import type { SessionRemotes } from './remotes.ts' + +/** + * List arrival lifecycle, orthogonal to the pull-activity `state` axis: + * `pending` (no successful pull yet — an empty items array means "nothing + * arrived", not "nothing exists") → `ready` (at least one pull landed). + * Monotone: `ready` never steps back — later pull failures and reconnect + * re-pulls ride the `state`/`error` axis, which is where failure is modeled + * (no `error` phase here; that would duplicate `state`). + */ +export type SessionListPhase = 'pending' | 'ready' + +/** Request-local content hit returned to sidebar search consumers. */ +export interface SessionSearchResultItem { + sessionId: SessionId + snippet: string +} + +/** Immutable session-list snapshot for useSessionList. */ +export interface SessionListSnapshot { + items: readonly SessionListEntry[] + /** Selected Session id (validated against items; masked to undefined while its session is off the list). */ + current: SessionId | undefined + state: 'idle' | 'loading' | 'error' + /** Arrival lifecycle (see {@link SessionListPhase}); `state` stays the pull-activity axis. */ + phase: SessionListPhase + error: ClientFailure | null + subagentsByParent: Readonly> + /** Background jobs per session; an absent key is an empty set. */ + jobsBySession: Readonly> + currentAddress: SubagentAddress | undefined +} + +/** One parent-addressed durable catalog projected through the sessions snapshot. */ +export type SubagentCatalogSnapshot = Omit & { + /** Absent until the first successful catalog read. */ + readonly parentAvailable?: boolean + state: 'loading' | 'ready' | 'error' + error: ClientFailure | null +} + +function catalogAvailability(parentAvailable: boolean | undefined): { + readonly parentAvailable?: boolean +} { + return parentAvailable === undefined ? {} : { parentAvailable } +} + +interface CatalogInflight { + readonly promise: Promise + readonly expandableRows: Set + readonly activityRows: Map + /** Removal-time invalidation replayed over the response this request predates. */ + parentAvailableOverride: false | undefined +} + +type SessionListMutation = + | { kind: 'upsert'; summary: SessionSummary } + | { kind: 'remove'; sessionId: SessionId } + | { kind: 'status'; sessionId: SessionId; running: boolean } + | { kind: 'activity'; sessionId: SessionId; updatedAt: number } + /** Local first-send flip: the sender clears blank without waiting for a host frame. */ + | { kind: 'engaged'; sessionId: SessionId } + +/** Instance cluster + frame entry + the session list. */ +export class SessionManager { + private readonly sessions = new Map() + /** In-flight Session disposals remain here after instances leave `sessions`, so manager disposal can await quiescence. */ + private readonly sessionDisposals = new Set>() + /** Latest transient queues, retained independently of Session object materialization. */ + private readonly queues = new Map() + /** + * Sessions that finished running while not selected — the sidebar's green + * "done" reminder (manager-owned, survives connection generations; cleared + * on select and session-removed, re-armed by the next completion). + */ + private readonly completedNotifications = new Set() + /** Last-observed running bits per session; the true→false edge here arms {@link completedNotifications}. */ + private readonly prevRunning = new Map() + /** Per-session projection value stores, retained independently of instance arrival (the + * title-snapshot precedent, generalized): push frames land here whether or not the Session + * is instantiated (list rows read the 'title' key), and an instantiated Session adopts the + * same store so history-baseline seeding and frames converge on one row set. */ + private readonly projectionStores = new Map() + private summaries: SessionSummary[] = [] + private listState: 'idle' | 'loading' | 'error' = 'idle' + /** Arrival phase; the pending → ready edge fires on the first successful pull (see SessionListPhase). */ + private listPhase: SessionListPhase = 'pending' + private listError: ClientFailure | null = null + private listInflight: Promise | null = null + /** Mutations arriving after a list request starts are replayed over its response. */ + private listMutations: SessionListMutation[] | null = null + private readonly addresses = new Map() + private readonly catalogs = new Map() + private readonly catalogInflight = new Map() + /** Catalog owners whose membership changed while a pull was in flight: one trailing refresh after it settles. */ + private readonly catalogStale = new Set() + private readonly openCatalogs = new Set() + private readonly catalogDebounce = new Map>() + /** + * Background jobs per session, last-wins from Session Controller's control + * stream. An empty set is stored as an absent key, so absence and `[]` are + * one representation. + */ + private readonly jobsBySession = new Map() + + private selected: SessionId | undefined + + private listSnapshotCache: SessionListSnapshot + /** Entry-identity cache (reference stability): list rebuilds reuse the previous entry + * object when every field matches — wire refreshes mint all-new summary objects, so identity + * must be recovered by value or every SessionListItem memo misses on every refresh. */ + private entryCache = new Map() + private itemsCache: readonly SessionListEntry[] = [] + private readonly notifier = new Notifier(() => { + this.listSnapshotCache = this.buildListSnapshot() + }) + + /** + * @param remote - generated Remote namespaces the Session cluster calls. + * @param restoredSelection - persisted real-Session selection candidate. + */ + constructor( + private readonly remote: SessionRemotes, + restoredSelection?: SessionId, + restoredAddress?: SubagentAddress, + ) { + this.selected = restoredSelection + if (restoredAddress !== undefined) this.addresses.set(restoredAddress.childSessionId, restoredAddress) + this.listSnapshotCache = this.buildListSnapshot() + } + + // ---- Selection ---- + + /** + * Select a listed Session or a retained catalog-addressed child. + * @param sessionId - listed or catalog-addressed Session id. + */ + select(sessionId: SessionId): void { + const address = this.navigationAddress(sessionId) + if (!this.summaries.some(summary => summary.sessionId === sessionId) && address === undefined) { + throw new Error(`sessions.select: unknown session ${sessionId}`) + } + if (address !== undefined) this.addresses.set(sessionId, address) + this.sessions.get(sessionId)?.configureSubagent( + address, + address === undefined + ? undefined + : this.catalogs.get(address.parentSessionId)?.parentAvailable, + ) + this.selected = sessionId + // Looking at the session consumes its completion reminder (dot clears). + this.completedNotifications.delete(sessionId) + void this.refreshSubagents(sessionId) + this.notifier.notifyNow() + } + + /** + * Select a healthy child through its durable direct-parent address. + * @param address - catalog-derived parent and child ids. + */ + selectSubagent(address: SubagentAddress): void { + const catalog = this.catalogs.get(address.parentSessionId) + const entry = catalog?.entries.find(candidate => candidate.id === address.childSessionId) + if (entry === undefined || entry.kind !== 'child' || entry.mode !== address.mode) { + throw new Error(`sessions.selectSubagent: ${address.childSessionId} is not a healthy catalog child`) + } + this.addresses.set(address.childSessionId, address) + this.sessions.get(address.childSessionId)?.configureSubagent(address, catalog?.parentAvailable) + this.selected = address.childSessionId + this.completedNotifications.delete(address.childSessionId) + void this.refreshSubagents(address.childSessionId) + this.notifier.notifyNow() + } + + /** Clear the selection (the layout falls to the no-session view state). */ + clearSelection(): void { + this.selected = undefined + this.notifier.notifyNow() + } + + /** + * Return the durable catalog address retained for one child. + * @param sessionId - possible addressed child id. + * @returns The direct-parent address, when navigation discovered one. + */ + subagentAddress(sessionId: SessionId): SubagentAddress | undefined { + return this.addresses.get(sessionId) + } + + /** + * Resolve an address for breadcrumb navigation without retaining transport authority. + * @param sessionId - possible child id in an already-loaded catalog. + * @returns A retained or catalog-derived direct-parent address. + */ + navigationAddress(sessionId: SessionId): SubagentAddress | undefined { + const retained = this.addresses.get(sessionId) + if (retained !== undefined) return retained + for (const [parentSessionId, catalog] of this.catalogs) { + const child = catalog.entries.find(entry => entry.kind === 'child' && entry.id === sessionId) + if (child?.kind === 'child') { + return { parentSessionId, childSessionId: sessionId, mode: child.mode } + } + } + return undefined + } + + // ---- Instance management ---- + + /** + * Drop a session instance (scope-prune companion: instance + * and scope share one lifecycle). The host session log is the durable + * truth — a later get() lazily rebuilds and open() backfills history. + * @param sessionId - the session to drop. + */ + async drop(sessionId: SessionId): Promise { + const session = this.sessions.get(sessionId) + this.sessions.delete(sessionId) + if (session !== undefined) await this.startSessionDisposal(session) + } + + /** + * Stop owned timers and every remaining Session instance. + * @returns when every Session Remote iterator has completed teardown. + */ + async dispose(): Promise { + for (const timer of this.catalogDebounce.values()) clearTimeout(timer) + this.catalogDebounce.clear() + this.catalogStale.clear() + this.openCatalogs.clear() + const sessions = [...this.sessions.values()] + this.sessions.clear() + for (const session of sessions) void this.startSessionDisposal(session) + await this.drainSessionDisposals() + } + + private startSessionDisposal(session: Session): Promise { + const disposal = session.dispose() + this.sessionDisposals.add(disposal) + void disposal.then( + () => { this.sessionDisposals.delete(disposal) }, + () => { this.sessionDisposals.delete(disposal) }, + ) + return disposal + } + + private async drainSessionDisposals(): Promise { + while (this.sessionDisposals.size > 0) { + await Promise.allSettled([...this.sessionDisposals]) + } + } + + /** + * Lazy build: return the existing instance or construct one (no auto-open — + * open is triggered by the container's select callback). + * @param sessionId - the session to get. + * @returns the resident instance. + */ + get(sessionId: SessionId): Session { + let session = this.sessions.get(sessionId) + if (session === undefined) { + session = this.createSession(sessionId) + this.sessions.set(sessionId, session) + // Install the latest control baseline before the running-bit sync: a + // not-running summary must sweep replayed queue + // rows the same way a live status flip would (their retirement events dropped + // while the session was uninstantiated). + session.replaceControl(this.queues.get(sessionId) ?? []) + // Sync the running and blank bits from the list snapshot into the new + // instance (consistency when the list precedes open). + const summary = this.summaries.find(s => s.sessionId === sessionId) + if (summary !== undefined) { + session.handleBlank(summary.blank) + session.handleRunning(summary.running) + } else { + const address = this.addresses.get(sessionId) + const child = address === undefined ? undefined : this.catalogs.get(address.parentSessionId)?.entries + .find(entry => entry.kind === 'child' && entry.id === sessionId) + if (child?.kind === 'child') { + // A catalogued child exists only after its delegated session has + // durable history, even though child rows do not carry `blank`. + session.handleBlank(false) + session.handleRunning(child.activity === 'running') + } + } + } + return session + } + + private createSession(sessionId: SessionId): Session { + const address = this.addresses.get(sessionId) + const parentAvailable = address === undefined + ? undefined + : this.catalogs.get(address.parentSessionId)?.parentAvailable + return new Session(sessionId, this.remote, { + ...(address === undefined ? {} : { + address, + ...catalogAvailability(parentAvailable), + }), + // The sender's local first-send flip mirrors into the list row so the + // session surfaces (lists filter on blank) before any host frame lands. + onEngaged: (engaged) => { + this.recordMutation({ kind: 'engaged', sessionId: engaged.sessionId }) + }, + projections: this.projectionStore(sessionId), + }) + } + + /** Resident per-session projection store (create-on-demand; outlives instantiation). */ + private projectionStore(sessionId: SessionId): ProjectionValueStore { + let store = this.projectionStores.get(sessionId) + if (store === undefined) { + store = new ProjectionValueStore() + // List rows project off store keys (title); any-key changes re-enter + // the manager's own batched rebuild channel. + store.subscribeAny(() => { this.notifier.markDirty() }) + this.projectionStores.set(sessionId, store) + } + return store + } + + /** + * Refresh one direct-child catalog, reusing its in-flight request. + * @param parentSessionId - catalog owner. + */ + refreshSubagents(parentSessionId: SessionId): Promise { + const existing = this.catalogInflight.get(parentSessionId) + if (existing !== undefined) return existing.promise + const previous = this.catalogs.get(parentSessionId) + const expandableRows = new Set() + const activityRows = new Map() + this.catalogs.set(parentSessionId, { + entries: previous?.entries ?? [], + ...(previous?.parentAvailable === undefined + ? {} + : { parentAvailable: previous.parentAvailable }), + state: 'loading', + error: null, + }) + this.notifier.markDirty() + const operation = (async () => { + try { + const result = toSessionResult(await this.remote.subagents.list(parentSessionId)) + if (result.ok) { + const parentAvailable = this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? result.value.parentAvailable + this.catalogs.set(parentSessionId, { + ...result.value, + entries: this.withCatalogMutations(result.value.entries, expandableRows, activityRows), + parentAvailable, + state: 'ready', + error: null, + }) + for (const [childId, address] of this.addresses) { + if (address.parentSessionId !== parentSessionId) continue + this.sessions.get(childId)?.handleSubagentParentAvailable(parentAvailable) + } + } else { + this.catalogs.set(parentSessionId, { + entries: this.withCatalogMutations( + previous?.entries ?? [], expandableRows, activityRows, + ), + ...catalogAvailability( + this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable, + ), + state: 'error', + error: result.error, + }) + } + } catch (error: unknown) { + const folded = transportResult(error) + this.catalogs.set(parentSessionId, { + entries: this.withCatalogMutations( + previous?.entries ?? [], expandableRows, activityRows, + ), + ...catalogAvailability( + this.catalogInflight.get(parentSessionId)?.parentAvailableOverride + ?? previous?.parentAvailable, + ), + state: 'error', + error: folded.ok ? null : folded.error, + }) + } finally { + this.catalogInflight.delete(parentSessionId) + // Re-arm the trailing pull before the dirty notify: the response the + // caller observed predates the stale-marking change, so the follow-up + // refresh is the only carrier of that change. + if (this.catalogStale.delete(parentSessionId)) void this.refreshSubagents(parentSessionId) + this.notifier.markDirty() + } + })() + this.catalogInflight.set(parentSessionId, { + promise: operation, + expandableRows, + activityRows, + parentAvailableOverride: undefined, + }) + return operation + } + + /** + * Mark whether a catalog menu is consuming live membership updates. + * @param parentSessionId - catalog owner. + * @param open - current menu state. + */ + setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void { + if (open) { + this.openCatalogs.add(parentSessionId) + void this.refreshSubagents(parentSessionId) + } else { + this.openCatalogs.delete(parentSessionId) + const timer = this.catalogDebounce.get(parentSessionId) + if (timer !== undefined) { + clearTimeout(timer) + this.catalogDebounce.delete(parentSessionId) + } + } + } + + // ---- List API ---- + + /** Full refresh via session.list (single-flight: an in-flight call is reused). */ + refreshList(): Promise { + if (this.listInflight !== null) return this.listInflight + this.listState = 'loading' + this.listError = null + const established = this.summaries + const mutations: SessionListMutation[] = [] + this.listMutations = mutations + this.notifier.markDirty() + this.listInflight = (async () => { + try { + const result = toSessionResult(await this.remote.session.list({})) + if (result.ok) { + const baseline: SessionSummary[] = this.listPhase === 'pending' + ? [...result.value.items] + : mergeOrderedBaseline(established, result.value.items, summary => summary.sessionId) + // Seed first observations from the pull-time baseline BEFORE replaying + // in-flight mutations, then reconcile the reminders after EVERY + // replayed mutation: an edge that happens entirely between mutations + // (baseline idle → running → idle) must still arm, which a single + // sync on the folded result would collapse away. + for (const s of baseline) { + if (!this.prevRunning.has(s.sessionId)) this.prevRunning.set(s.sessionId, s.running) + } + let summaries = baseline + for (const mutation of mutations) { + summaries = applyMutation(summaries, mutation) + this.summaries = summaries + this.syncCompletedNotifications() + } + this.summaries = summaries + this.listState = 'idle' + this.listPhase = 'ready' + // Covers the empty-mutations pull (a plain baseline carries no edge). + this.syncCompletedNotifications() + // Push running/blank bits down to instantiated Sessions (the list is the authoritative summary source). + for (const s of this.summaries) { + const session = this.sessions.get(s.sessionId) + if (session === undefined) continue + session.handleBlank(s.blank) + session.handleRunning(s.running) + } + // Seed each row's projection baseline into the per-session value + // store (cold titles surface without opening the session). Per-key + // apply, not seed(): the list block is a partial baseline — the + // cold cache serves only version-matching keys — so an absent key + // must not clear; higher-seq-wins still keeps a stale list block + // from overwriting a newer push frame or tail baseline. + for (const s of result.value.items) { + const block = s.projections + if (block === undefined) continue + const store = this.projectionStore(s.sessionId) + const values = block.values as Record + for (const key of Object.keys(values)) store.apply(key, values[key], block.asOfSeq) + } + } else { + this.listState = 'error' + this.listError = result.error + } + } catch (error) { + this.listState = 'error' + const folded = transportResult(error) + /* v8 ignore next -- the `? null` arm is unreachable: transportResult always returns ok:false. */ + this.listError = folded.ok ? null : folded.error + } finally { + this.listMutations = null + this.listInflight = null + this.notifier.markDirty() + } + })() + return this.listInflight + } + + /** + * Search visible session message content without adding transient query + * state to the list snapshot. + * @param query - non-blank literal phrase. + * @param signal - cancellation for superseded UI queries. + * @returns the Host result or a folded transport error. + */ + async search( + query: string, + signal: AbortSignal, + ): Promise> { + try { + const result = toSessionResult(await this.remote.session.search({ query }, signal)) + if (!result.ok) return result + return { + ok: true, + value: { + items: [...result.value.items], + hasMore: result.value.hasMore, + }, + } + } catch (error: unknown) { + return transportResult(error) + } + } + + /** + * Contract session.create; on success merge into summaries immediately (no + * wait for the next refresh). A created session is blank by definition + * (entity birth precedes the first message). + * @param opts - target workspace or working directory, plus an optional caller-owned id. + * @returns the create result. + */ + async create( + opts: { + workspaceId?: WorkspaceId + cwd?: string + sessionId?: SessionId + } = {}, + ): Promise> { + try { + const shared = opts.sessionId === undefined ? {} : { sessionId: opts.sessionId } + const payload = opts.workspaceId !== undefined + ? { workspaceId: opts.workspaceId, ...shared } + : { ...(opts.cwd === undefined ? {} : { cwd: opts.cwd }), ...shared } + const result = toSessionResult(await this.remote.session.create(payload)) + if (result.ok) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: result.value.sessionId, updatedAt: Date.now(), running: false, blank: true, + ...(opts.cwd !== undefined ? { cwd: opts.cwd } : {}), + } }) + } else { + const publishedSessionId = workspaceAttachSessionId(result.error) + // Publication precedes attachment. The error's id is a real Session, + // so expose it immediately as Ungrouped while the caller keeps the + // prompt buffer and decides whether to retry attachment. + if (publishedSessionId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: publishedSessionId, + updatedAt: Date.now(), + running: false, + blank: true, + } }) + } + } + return result + } catch (error) { + return transportResult(error) + } + } + + /** + * Contract session.fork; on success merge the child into summaries + * immediately (same synchronous-addressability guarantee as create). The + * child carries the source's history, so it is never blank; lineage rides + * parentSessionId so the list nests it under its source. A child published + * before Workspace attachment fails is also reconciled into the list. + * @param opts - source session and the optional seq anchoring the cut. + * @returns the fork result (the child session id). + */ + async fork( + opts: { sessionId: SessionId; atSeq?: number }, + ): Promise> { + try { + const source = this.summaries.find(s => s.sessionId === opts.sessionId) + const result = toSessionResult(await this.remote.session.fork({ + sessionId: opts.sessionId, + ...opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }, + })) + const childId = result.ok + ? result.value.sessionId + : workspaceAttachSessionId(result.error) + if (childId !== undefined) { + this.recordMutation({ kind: 'upsert', summary: { + sessionId: childId, updatedAt: Date.now(), running: false, blank: false, + parentSessionId: opts.sessionId, + ...(source?.cwd !== undefined ? { cwd: source.cwd } : {}), + } }) + } + return result + } catch (error) { + return transportResult(error) + } + } + + /** + * Insert-or-enrich a locally synthesized summary: a new id prepends; an + * existing entry only gains fields it lacks (the session-added frame and the + * create() echo race — whichever lands second must fill the placeholder's + * missing cwd/parentSessionId, never overwrite list-refresh data). + */ + private mergeSummary(summary: SessionSummary): void { + this.recordMutation({ kind: 'upsert', summary }) + } + + /** Apply immediately and retain for replay when a list response is in flight. */ + private recordMutation(mutation: SessionListMutation): void { + this.listMutations?.push(mutation) + this.summaries = applyMutation(this.summaries, mutation) + // Eager edge reconciliation — a snapshot-build-time pass would miss consecutive status frames. + this.syncCompletedNotifications() + this.notifier.markDirty() + } + + // ---- Subscription API (for useSessionList) ---- + + /** + * uSES subscription entry for useSessionList. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Cached list snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getListSnapshot(): SessionListSnapshot { + this.notifier.ensureFresh() + return this.listSnapshotCache + } + + // ---- Live control and Host-event sinks ---- + + /** + * Apply a complete control baseline or one later replacement frame. + * @param frame - baseline or live control replacement from Session Controller. + */ + handleControlFrame(frame: SessionControlFrame): void { + if (frame.type === 'baseline') { + this.replaceControlBaseline(frame.value) + return + } + if (frame.type === 'projection') { + this.projectionStore(frame.sessionId).apply(frame.key, frame.value, frame.seq) + this.notifier.markDirty() + return + } + if (frame.type === 'jobs') { + if (frame.jobs.length === 0) this.jobsBySession.delete(frame.sessionId) + else this.jobsBySession.set(frame.sessionId, frame.jobs) + this.notifier.markDirty() + return + } + this.queues.set(frame.sessionId, frame.items) + this.sessions.get(frame.sessionId)?.handleControlFrame(frame) + } + + private replaceControlBaseline(baseline: SessionControlBaseline): void { + this.queues.clear() + for (const [sessionId, items] of Object.entries(baseline.queues)) { + this.queues.set(sessionId as SessionId, items) + } + + this.jobsBySession.clear() + for (const [sessionId, jobs] of Object.entries(baseline.jobs)) { + if (jobs.length > 0) this.jobsBySession.set(sessionId as SessionId, jobs) + } + + for (const [sessionId, block] of Object.entries(baseline.projections)) { + const store = this.projectionStore(sessionId as SessionId) + store.truncate(block.asOfSeq) + store.seed(block) + } + for (const [sessionId, session] of this.sessions) { + session.replaceControl(this.queues.get(sessionId) ?? []) + } + this.notifier.markDirty() + } + + /** + * Apply one Session-list addition forwarded through `ctx.remote.$on`. + * @param summary - current Host summary for the added Session. + */ + handleSessionAdded(summary: SessionSummary): void { + this.mergeSummary(summary) + this.sessions.get(summary.sessionId)?.handleBlank(summary.blank) + const projections = summary.projections + if (projections !== undefined) { + const store = this.projectionStore(summary.sessionId) + for (const [key, value] of Object.entries(projections.values)) { + store.apply(key, value, projections.asOfSeq) + } + } + if (summary.origin === 'subagent' && summary.parentSessionId !== undefined) { + this.markCatalogParentExpandable(summary.parentSessionId) + } + if (summary.parentSessionId !== undefined + && (this.selected === summary.parentSessionId || this.openCatalogs.has(summary.parentSessionId))) { + this.scheduleCatalogRefresh(summary.parentSessionId) + } + } + + /** + * Apply one Session removal forwarded through `ctx.remote.$on`. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: SessionId): void { + const summary = this.summaries.find(candidate => candidate.sessionId === sessionId) + const durableSubagent = summary?.origin === 'subagent' || this.addresses.has(sessionId) + this.recordMutation(durableSubagent + ? { kind: 'status', sessionId, running: false } + : { kind: 'remove', sessionId }) + this.updateCatalogActivity(sessionId, false) + if (durableSubagent) this.sessions.get(sessionId)?.handleRunning(false) + else this.sessions.get(sessionId)?.handleRemoved() + this.queues.delete(sessionId) + this.jobsBySession.delete(sessionId) + if (!durableSubagent) this.projectionStores.delete(sessionId) + const inflightCatalog = this.catalogInflight.get(sessionId) + if (inflightCatalog !== undefined) { + inflightCatalog.parentAvailableOverride = false + this.catalogStale.add(sessionId) + } + const ownedCatalog = this.catalogs.get(sessionId) + if (ownedCatalog !== undefined && ownedCatalog.parentAvailable) { + this.catalogs.set(sessionId, { ...ownedCatalog, parentAvailable: false }) + } + for (const [childId, address] of this.addresses) { + if (address.parentSessionId === sessionId) { + this.sessions.get(childId)?.handleSubagentParentAvailable(false) + } + } + } + + /** + * Apply one live Agent running-state change. + * @param sessionId - Session whose Agent state changed. + * @param running - current Agent running state. + */ + handleSessionStatus(sessionId: SessionId, running: boolean): void { + this.recordMutation({ kind: 'status', sessionId, running }) + this.sessions.get(sessionId)?.handleRunning(running) + this.updateCatalogActivity(sessionId, running) + } + + /** + * Advance Session-list activity from one user-authored durable message. + * @param sessionId - Session whose activity changed. + * @param updatedAt - durable message timestamp. + */ + handleSessionActivity(sessionId: SessionId, updatedAt: number): void { + this.recordMutation({ kind: 'activity', sessionId, updatedAt }) + } + + /** + * Surface one live Agent failure on an already-materialized Session. + * @param sessionId - Session whose Agent failed. + * @param message - caller-visible failure description. + */ + handleSessionError(sessionId: SessionId, message: string): void { + this.sessions.get(sessionId)?.handleAgentError(message) + } + + /** + * Repair one re-established Host-event generation with queryable baselines. + * Opened Session follow streams resume independently through API Gateway. + */ + handleConnected(): void { + void this.refreshList() + const selectedAddress = this.selected === undefined ? undefined : this.addresses.get(this.selected) + if (selectedAddress !== undefined) void this.refreshSubagents(selectedAddress.parentSessionId) + if (this.selected !== undefined) void this.refreshSubagents(this.selected) + for (const parentSessionId of this.openCatalogs) void this.refreshSubagents(parentSessionId) + } + + /** Debounce membership refetches while one parent catalog is selected or open. */ + private scheduleCatalogRefresh(parentSessionId: SessionId): void { + if (this.catalogDebounce.has(parentSessionId)) return + const timer = setTimeout(() => { + this.catalogDebounce.delete(parentSessionId) + // The in-flight response predates the membership frame that scheduled + // this callback. Queue one post-settlement pull instead of treating an + // ordinary overlapping read as evidence that catalog membership changed. + if (this.catalogInflight.has(parentSessionId)) { + this.catalogStale.add(parentSessionId) + return + } + void this.refreshSubagents(parentSessionId) + }, 50) + this.catalogDebounce.set(parentSessionId, timer) + } + + /** Apply one Agent-driver transition to loaded and in-flight catalogs. */ + private updateCatalogActivity(childSessionId: SessionId, running: boolean): void { + const activity = running ? 'running' as const : 'inactive' as const + for (const inflight of this.catalogInflight.values()) { + inflight.activityRows.set(childSessionId, activity) + } + let changed = false + for (const [parentSessionId, catalog] of this.catalogs) { + if (!catalog.entries.some(entry => + entry.kind === 'child' && entry.id === childSessionId && entry.activity !== activity)) continue + const entries = catalog.entries.map((entry) => { + if (entry.kind !== 'child' || entry.id !== childSessionId) return entry + return { ...entry, activity } + }) + changed = true + this.catalogs.set(parentSessionId, { ...catalog, entries }) + } + if (changed) this.notifier.markDirty() + } + + /** Preserve and project a positive expandability hint after one direct subagent publishes. */ + private markCatalogParentExpandable(parentSessionId: SessionId): void { + this.applyCatalogParentExpandable(parentSessionId) + for (const inflight of this.catalogInflight.values()) inflight.expandableRows.add(parentSessionId) + } + + /** Apply one positive expandability hint to every loaded catalog containing that unique row id. */ + private applyCatalogParentExpandable(parentSessionId: SessionId): void { + let changed = false + for (const [catalogParentId, catalog] of this.catalogs) { + if (!catalog.entries.some(entry => + entry.kind === 'child' && entry.id === parentSessionId && !entry.hasChildren)) continue + const entries = catalog.entries.map((entry) => { + if (entry.kind !== 'child' || entry.id !== parentSessionId || entry.hasChildren) return entry + return { ...entry, hasChildren: true } + }) + changed = true + this.catalogs.set(catalogParentId, { ...catalog, entries }) + } + if (changed) this.notifier.markDirty() + } + + /** Fold request-local row mutations into one catalog result before publication. */ + private withCatalogMutations( + entries: SubagentCatalog['entries'], + expandableRows: ReadonlySet, + activityRows: ReadonlyMap, + ): SubagentCatalog['entries'] { + return entries.map((entry) => { + if (entry.kind !== 'child') return entry + const activity = activityRows.get(entry.id) + if (!expandableRows.has(entry.id) && activity === undefined) return entry + return { + ...entry, + ...expandableRows.has(entry.id) ? { hasChildren: true } : {}, + ...activity === undefined ? {} : { activity }, + } + }) + } + + /** + * Reconcile completion reminders against the latest summaries, eagerly after + * every mutation and pull (a snapshot-build-time pass would collapse + * consecutive status frames into one observation). A running→idle edge of a + * non-selected session arms its reminder; running disarms it; removal drops + * it. First observation only records the running bit — sessions already + * idle at load get no reminder. + */ + private syncCompletedNotifications(): void { + const seen = new Set() + for (const s of this.summaries) { + seen.add(s.sessionId) + const prev = this.prevRunning.get(s.sessionId) + if (prev === undefined) { + this.prevRunning.set(s.sessionId, s.running) + continue + } + if (prev && !s.running) { + if (s.sessionId !== this.selected) this.completedNotifications.add(s.sessionId) + } else if (s.running) { + this.completedNotifications.delete(s.sessionId) + } + this.prevRunning.set(s.sessionId, s.running) + } + for (const id of this.prevRunning.keys()) { + if (!seen.has(id)) this.prevRunning.delete(id) + } + for (const id of this.completedNotifications) { + if (!seen.has(id)) this.completedNotifications.delete(id) + } + } + + private buildListSnapshot(): SessionListSnapshot { + const merged: TitledSessionSummary[] = this.summaries.map((summary) => { + // List rows read the generic 'title' projection key (host-computed unit + // value; there is no dedicated title frame). + const projectionStore = this.projectionStores.get(summary.sessionId) + const title = projectionStore?.get('title') + const projectionValues = projectionStore?.values() + return { + ...summary, + ...(typeof title === 'string' && title !== '' ? { title } : {}), + ...(projectionValues === undefined ? {} : { projectionValues }), + } + }) + const fresh = flattenLineage(merged, this.completedNotifications) + const items = fresh.map((entry) => { + const prev = this.entryCache.get(entry.sessionId) + if ( + prev !== undefined && prev.updatedAt === entry.updatedAt && prev.running === entry.running + && prev.blank === entry.blank + && prev.parentSessionId === entry.parentSessionId && prev.cwd === entry.cwd + && prev.origin === entry.origin && prev.title === entry.title && prev.depth === entry.depth + && prev.projectionValues === entry.projectionValues + && prev.completed === entry.completed + ) return prev + this.entryCache.set(entry.sessionId, entry) + return entry + }) + for (const id of this.entryCache.keys()) { + if (!items.some(e => e.sessionId === id)) this.entryCache.delete(id) + } + const sameOrder = items.length === this.itemsCache.length && items.every((e, i) => e === this.itemsCache[i]) + if (!sameOrder) this.itemsCache = items + const selected = this.selected + const current = selected !== undefined + && (items.some(item => item.sessionId === selected) || this.addresses.has(selected)) + ? selected + : undefined + return { + items: this.itemsCache, + current, + state: this.listState, + phase: this.listPhase, + error: this.listError, + subagentsByParent: Object.fromEntries(this.catalogs), + jobsBySession: Object.fromEntries(this.jobsBySession), + currentAddress: current === undefined ? undefined : this.addresses.get(current), + } + } +} + +/** Apply one list mutation without deriving display order. */ +function applyMutation(summaries: readonly SessionSummary[], mutation: SessionListMutation): SessionSummary[] { + switch (mutation.kind) { + case 'upsert': { + const existing = summaries.find(summary => summary.sessionId === mutation.summary.sessionId) + if (existing === undefined) return [mutation.summary, ...summaries] + const filled: SessionSummary = { + ...existing, + // Blank only lowers: a stale true (session-added racing the local + // first send) never re-hides an already-surfaced session. + blank: existing.blank && mutation.summary.blank, + ...(existing.cwd === undefined && mutation.summary.cwd !== undefined ? { cwd: mutation.summary.cwd } : {}), + ...(existing.parentSessionId === undefined && mutation.summary.parentSessionId !== undefined + ? { parentSessionId: mutation.summary.parentSessionId } : {}), + ...(existing.origin === undefined && mutation.summary.origin !== undefined + ? { origin: mutation.summary.origin } : {}), + } + if (filled.cwd === existing.cwd && filled.parentSessionId === existing.parentSessionId + && filled.origin === existing.origin && filled.blank === existing.blank + ) return [...summaries] + return summaries.map(summary => summary.sessionId === mutation.summary.sessionId ? filled : summary) + } + case 'remove': + return summaries.filter(summary => summary.sessionId !== mutation.sessionId) + case 'status': + // running:true doubles as the cross-client blank flip (a blank session + // never runs, so the first running frame proves a message landed). + return summaries.map(summary => summary.sessionId === mutation.sessionId + && (summary.running !== mutation.running || (mutation.running && summary.blank)) + ? { ...summary, running: mutation.running, blank: summary.blank && !mutation.running } + : summary) + case 'activity': + return summaries.map(summary => summary.sessionId === mutation.sessionId + && mutation.updatedAt > summary.updatedAt + ? { ...summary, updatedAt: mutation.updatedAt } + : summary) + case 'engaged': + return summaries.map(summary => summary.sessionId === mutation.sessionId && summary.blank + ? { ...summary, blank: false } + : summary) + } +} + +/** Temporary source-plane bridge while the Host contract and client project build independently. */ +function workspaceAttachSessionId(error: ClientFailure): SessionId | undefined { + return error.code === 'workspace-attach-failed' ? error.details.sessionId : undefined +} + +/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ +function toSessionResult( + result: import('@deepseek-ai/dsh-typert-protocol').RemoteResult, +): ClientResult { + return result.ok ? result : { ok: false, error: result.error as SessionError } +} diff --git a/packages/api/session-controller/src/client/sessions/notifier.ts b/packages/api/session-controller/src/client/sessions/notifier.ts new file mode 100644 index 0000000000..660c8645b4 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/notifier.ts @@ -0,0 +1,97 @@ +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' + +/** + * Batches structural updates in microtasks and stream updates by animation + * frame. Reads may rebuild a dirty snapshot without consuming the pending + * subscriber notification. + */ +export class Notifier { + private listeners = new Set<() => void>() + private dirty = false + private notifyPending = false + private scheduled: 'none' | 'microtask' | 'frame' = 'none' + private scheduleGeneration = 0 + + /** @param rebuild - snapshot rebuild function injected by the owner (writes the owner's snapshotCache). */ + constructor(private readonly rebuild: () => void) {} + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { + this.listeners.delete(listener) + } + } + + /** Mark the snapshot dirty and notify in a microtask. */ + markDirty(): void { + this.dirty = true + this.notifyPending = true + if (this.scheduled === 'microtask') return + this.schedule('microtask') + } + + /** Mark the snapshot dirty and publish cumulative state at most once per frame. */ + markFrameDirty(): void { + this.dirty = true + this.notifyPending = true + if (this.scheduled !== 'none') return + this.schedule(typeof globalThis.requestAnimationFrame === 'function' ? 'frame' : 'microtask') + } + + /** + * Synchronous flush: controlled-input writes must notify in the same tick as + * onChange, or React rolls the DOM back to the stale value and the caret jumps to the end. + */ + notifyNow(): void { + this.dirty = true + this.notifyPending = true + this.invalidateSchedule() + this.flush() + } + + /** + * Pre-getSnapshot check: rebuild synchronously when dirty (read path + * before first subscribe / while unobserved). Notification stays pending. + */ + ensureFresh(): void { + if (!this.dirty) return + this.dirty = false + this.rebuild() + } + + private schedule(kind: 'microtask' | 'frame'): void { + const generation = ++this.scheduleGeneration + this.scheduled = kind + const publish = () => { + if (generation !== this.scheduleGeneration) return + this.scheduled = 'none' + this.flush() + } + if (kind === 'frame') { + globalThis.requestAnimationFrame(publish) + } else { + queueMicrotask(publish) + } + } + + private invalidateSchedule(): void { + this.scheduleGeneration++ + this.scheduled = 'none' + } + + private flush(): void { + if (!this.notifyPending) return + if (this.listeners.size === 0) return // lazy: dirty (if still set) rebuilds on next getSnapshot + this.notifyPending = false + if (this.dirty) { + this.dirty = false + this.rebuild() + } + notifySubscribers(this.listeners, '[session-controller]') + } +} diff --git a/packages/client/runtime/src/client/sessions/projection-store.ts b/packages/api/session-controller/src/client/sessions/projection-store.ts similarity index 89% rename from packages/client/runtime/src/client/sessions/projection-store.ts rename to packages/api/session-controller/src/client/sessions/projection-store.ts index ba3588c46d..fd0ad1b5d2 100644 --- a/packages/client/runtime/src/client/sessions/projection-store.ts +++ b/packages/api/session-controller/src/client/sessions/projection-store.ts @@ -2,14 +2,14 @@ * Generic per-session projection value store (push model; see the * session-projection subsystem page, docs/subsystems/session-projection.md): * the host is the only computation site; the client holds finished - * whole values per key — `key → { value, seq }` — seeded by the history tail - * page's projections block and updated by `session/projection` push frames, + * whole values per key — `key → { value, seq }` — seeded by a follow opening + * baseline and updated by Session Controller `projection` frames, * under the single rule **higher seq wins**. No client-side domain folding * exists: a domain ships projection support with zero client code. Per-key * bare observable faces feed `useProjection` (ui-renderer binds them). */ import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' -import type { ObservableSnapshot } from '../contract/store.ts' +import type { ObservableSnapshot } from '@deepseek-ai/dsh-client-store' import { Notifier } from './notifier.ts' // The single projection type table, typed end to end (host unit, wire block, @@ -40,8 +40,7 @@ export type UseProjection = { } /** - * Tail-page projections baseline — structurally identical to the wire's - * `SessionProjectionsBlock` (apiproxy api layer), restated here so the + * Follow-opening projection baseline, restated here so the * React-free store depends only on the type table, not the wire package's * response vocabulary. */ @@ -49,7 +48,7 @@ export interface ProjectionsBaseline { /** The consistent-cut seq (equals the window tail seq by construction). */ asOfSeq: number /** Whole current values by key; a registered key absent here means the capability is absent. */ - values: Partial + values: Readonly> } /** One key's row: the latest finished value and the seq it is consistent with. */ @@ -126,7 +125,7 @@ export class ProjectionValueStore { } /** - * Apply one finished value (the `session/projection` push-frame path). + * Apply one finished value from the Session control stream. * @param key - projection key. * @param value - whole value computed by the host unit. * @param seq - the unit's watermark at emission. @@ -160,13 +159,11 @@ export class ProjectionValueStore { } /** - * Drop rows past a mux-generation baseline (`session/subscribed.lastSeq`): - * a row claiming knowledge beyond the host's own durable baseline rode - * state a restart lost — under last-wins it would wrongly outrank the - * host's recomputed (lower-seq) values forever. Durable replay and the next - * baseline re-seed whatever truly survived (the title-snapshot precedent, - * generalized). - * @param lastSeq - the subscribed frame's durable baseline seq. + * Drop rows beyond a replacement control baseline. Such rows describe + * process state the Host lost before persisting it and would otherwise + * outrank recomputed lower-seq values forever. The caller seeds the new + * baseline immediately afterward. + * @param lastSeq - highest durable sequence reflected by the baseline. */ truncate(lastSeq: number): void { for (const [key, row] of this.rows) { diff --git a/packages/api/session-controller/src/client/sessions/queue-mirror.ts b/packages/api/session-controller/src/client/sessions/queue-mirror.ts new file mode 100644 index 0000000000..209349af2c --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/queue-mirror.ts @@ -0,0 +1,67 @@ +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { SessionQueuedItem } from '../../types.ts' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { QueuedMessage } from '../contract/snapshot.ts' + +const QUEUE_PREVIEW_CHARS = 200 + +function previewOf(content: readonly ContentBlock[]): string { + const flat = content + .map(block => (block.type === 'text' ? block.text : `[${block.type}]`)) + .join(' ').replace(/\s+/g, ' ').trim() + const chars = Array.from(flat) + return chars.length > QUEUE_PREVIEW_CHARS ? `${chars.slice(0, QUEUE_PREVIEW_CHARS).join('')}…` : flat +} + +function textOf(content: readonly ContentBlock[]): string | null { + if (!content.every(block => block.type === 'text')) return null + return content.map(block => block.text).join('') +} + +type QueueItems = readonly SessionQueuedItem[] + +/** Authoritative transient queue projection and durable steering handoff. */ +export class SessionQueueMirror { + private current: readonly QueuedMessage[] = [] + + /** + * Return the current immutable queue projection. + * @returns current queue rows. + */ + snapshot(): readonly QueuedMessage[] { + return this.current + } + + /** + * Replace from one authoritative stream queue frame. + * @param items - complete host queue snapshot. + */ + replace(items: QueueItems): void { + this.current = items.map((item) => { + const content = item.message.content as unknown as readonly ContentBlock[] + return { + id: item.id, + messageId: item.message.id, + placement: item.placement, + content, + preview: previewOf(content), + text: textOf(content), + } + }) + } + + /** + * Retire a transient steering row once its durable message enters the log. + * @param event - newly contiguous durable Session event. + * @returns whether the projection changed. + */ + acceptDurable(event: SessionEvent): boolean { + if (event.type !== 'user/message') return false + const messageId = event.data.id + const index = this.current.findIndex(item => + item.placement === 'steering' && item.messageId === messageId) + if (index < 0) return false + this.current = this.current.filter((_item, candidate) => candidate !== index) + return true + } +} diff --git a/packages/api/session-controller/src/client/sessions/remotes.ts b/packages/api/session-controller/src/client/sessions/remotes.ts new file mode 100644 index 0000000000..bec8709b90 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/remotes.ts @@ -0,0 +1,47 @@ +/** + * Remote namespaces the Session cluster calls. One parameter for one concept: + * the generated surface a Session and its manager reach the Host through. + * + * @module @deepseek-ai/dsh-api-session-controller/client/sessions/remotes + */ + +import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' +import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { + SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, SubagentPromptRequest, +} from '@deepseek-ai/dsh-subagent/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionRemote } from '../transport.ts' + +/** Narrow Commands namespace consumed by a Client Session. */ +export interface SessionCommandsRemote { + execute( + agentId: SessionId, + line: string, + images: readonly EncodedImageAttachment[], + signal?: AbortSignal, + ): Promise> +} + +/** Narrow subagent namespace consumed by a Client Session and its manager. */ +export interface SessionSubagentsRemote { + list(parentSessionId: SessionId, signal?: AbortSignal): Promise> + prompt( + request: SubagentPromptRequest, + signal?: AbortSignal, + ): Promise> + interruptByParent( + childSessionId: SessionId, + parentSessionId: SessionId, + mode: 'continuable', + ): Promise> +} + +/** Generated Remote namespaces consumed by the Client Session object layer. */ +export interface SessionRemotes { + readonly $stream: ClientRemote['$stream'] + readonly commands: SessionCommandsRemote + readonly session: SessionRemote + readonly subagents: SessionSubagentsRemote +} diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts new file mode 100644 index 0000000000..a54d1ece0d --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -0,0 +1,719 @@ +/** + * ClientSessions: root sessions service — list snapshot store (manager + * projection; carries `current`, the persisted selection every + * session-scoped surface keys off), Agent scope tree (mintScope pattern: no-op plugin + * Fiber + ctx.extend scope tag; one scope per session, agent id === session + * id), stable SessionBinding cache, breadcrumb-route projection. + * + * 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 (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 '@deepseek-ai/cordis' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { workspaceTitleOf } from '@deepseek-ai/dsh-util-workspace-path' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import { SESSION_SEARCH_RESULT_LIMIT } from '../../types.ts' +import type { SessionJob as JobView } from '../../types.ts' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import { + createSnapshotStore, type SnapshotStore, +} from '@deepseek-ai/dsh-client-store' +import type { ClientFailure, ClientResult } from '../contract/result.ts' +import type { SessionEventSource } from '../contract/events.ts' +import type { SessionFace } from '../contract/session.ts' +import type { AgentContext, ISessions } from '../contract/sessions.ts' +import { createScope, scopeOf as scopeTagOf } from '../scope.ts' +import { SessionManager } from './manager.ts' +import type { SessionRemotes } from './remotes.ts' +import type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } 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 + /** Coarse durable origin for navigation filtering; not a continuation capability. */ + origin?: 'subagent' + running: boolean + /** Finished while not selected and not yet opened — the sidebar's green "done" reminder. Absent = false. */ + completed?: boolean + /** + * Empty-log bit (host summary derivation mirror). New Session reuses a blank + * one targeting the same workspace. Filtering stays with the consumer: the + * store carries every row, while the Workspace browser shows only the + * selected blank entry. + */ + blank: boolean + updatedAt: number + /** Current host-computed projection values retained by the object layer. */ + projectionValues?: Readonly> +} + +/** + * Session list store shape. `current` rides the same snapshot (arbitrated: + * the single useSessions standard hook reads list and selection together — + * sidebar highlighting and current-session consumers share one fact source). + */ +export interface SessionListState { + /** Host-list order; addressed breadcrumb-only rows are excluded. */ + ids: SessionId[] + /** Host rows plus the current addressed subagent route used by navigation. */ + 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 + /** Direct durable catalogs keyed by their selected parent address. */ + subagentsByParent: Readonly> + /** + * Background jobs each session can see, mirrored last-wins from Session + * Controller's control baseline and `jobs` frames. A missing key is an empty + * set, so consumers read absence rather than a sentinel. + */ + jobsBySession: Readonly> + /** Current session's catalog-derived address, absent on ordinary navigation. */ + currentAddress: SubagentAddress | undefined +} + +/** Persisted navigation cell: address survives refresh for correct history routing. */ +interface SessionSelection { + sessionId?: SessionId + subagentAddress?: SubagentAddress +} + +/** 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: ClientFailure, + readonly requestedSessionId: SessionId | undefined, + ) { + super(`session create failed: ${rpcError.code}: ${rpcError.message}`) + } +} + +/** Structured session-fork failure. */ +export class SessionForkError extends Error { + override readonly name = 'SessionForkError' + + /** + * @param rpcError - Host business or folded transport error. + * @param sourceSessionId - the session the fork was cut from. + */ + constructor( + readonly rpcError: ClientFailure, + readonly sourceSessionId: SessionId, + ) { + super(`session fork failed: ${rpcError.code}: ${rpcError.message}`) + } +} + +/** Identity-stable logical binding for one materialized Client Session. */ +export interface SessionBinding { + readonly sessionId: SessionId + /** The outward session face only — feature code never sees the concrete class. */ + readonly session: SessionFace + /** Contiguous event window reserved for Conversation assembly. */ + readonly eventSource: SessionEventSource + readonly ctx: AgentContext +} + +// Scope primitives live in ../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 '../scope.ts' + +/** + * 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 +} + +/** + * Increment a trailing fork number while preserving its half-width or + * full-width parentheses; an unnumbered title starts with ` (1)`. + * @param title - source session's durable title. + * @returns the title assigned to the fork child. + */ +function increasedForkTitle(title: string): string { + const ascii = /^(.*?)\((\d+)\)$/u.exec(title) + if (ascii?.[1] !== undefined && ascii[2] !== undefined) { + return `${ascii[1]}(${BigInt(ascii[2]) + 1n})` + } + const fullWidth = /^(.*?)((\d+))$/u.exec(title) + if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) { + return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})` + } + return `${title} (1)` +} + +interface ScopeRecord { + fiber: Fiber + ctx: AgentContext + binding: SessionBinding + /** The concrete Session for runtime-internal entry points (staging open()); the binding carries only the outward face. */ + session: Session +} + +/** Root sessions service: list store, current selection, object-layer manager, scope tree, bindings, and breadcrumb routes. */ +export class ClientSessions implements ISessions { + /** + * The wire schema's own result bound, re-exposed for presentation plugins as + * injected data. Not per-connection state: the `session.search` response + * schema caps `items` at this constant, so every transport (fixture included) + * reports the same number. + */ + readonly searchResultLimit = SESSION_SEARCH_RESULT_LIMIT + /** 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 + * ClientSessions.open} / {@link ClientSessions.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 + + private readonly scopes = new Map() + /** In-flight scope drops remain here after records leave `scopes`, so root disposal can await quiescence. */ + private readonly scopeDrops = new Set>() + /** + * 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 remote - generated Remote namespaces shared with every Session. + */ + constructor( + private readonly rootCtx: Context, + remote: SessionRemotes, + ) { + this.selection = createSnapshotStore( + {}, + { persist: { name: 'dsh.sessions.current' } }) + const restored = this.selection.getSnapshot() + this.manager = new SessionManager( + remote, + restored.sessionId, + restored.subagentAddress, + ) + this.list = createSnapshotStore({ + ids: [], byId: {}, current: undefined, phase: 'pending', + subagentsByParent: {}, jobsBySession: {}, currentAddress: undefined, + }) + // The manager owns wire truth; the store is its projection. Manager + // notifications are already microtask-batched. + const disposeManagerProjection = 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. + const disposeStageFollower = this.list.subscribe(() => { + this.followCurrent() + }) + rootCtx.effect(() => async () => { + disposeStageFollower() + disposeManagerProjection() + const scopes = [...this.scopes] + this.scopes.clear() + this.deferredRemovals.clear() + this.watched = undefined + for (const [id, record] of scopes) this.startScopeDrop(id, record) + await this.drainScopeDrops() + await this.manager.dispose() + }, 'session-controller.client.sessions') + rootCtx.reflect.provide('sessions', this, undefined) + } + + /** + * Select a listed or retained catalog-addressed session as current. + * @param id - listed or addressed session id. + */ + open(id: SessionId): void { + this.manager.select(id) + } + + /** + * Open a healthy catalog child through its direct-parent address. + * @param address - catalog-derived parent and child ids. + */ + openSubagent(address: SubagentAddress): void { + this.manager.selectSubagent(address) + } + + /** + * Resolve an already discovered direct-parent address without opening it. + * Feature plugins use this to avoid Agent-bound RPCs in persisted child views. + * @param id - possible addressed child id. + * @returns The retained address, when present. + */ + subagentAddress(id: SessionId): SubagentAddress | undefined { + return this.manager.subagentAddress(id) + } + + /** + * Inform the Session Controller whether a catalog menu is consuming membership updates. + * @param parentSessionId - selected parent. + * @param open - menu state. + */ + setSubagentCatalogOpen(parentSessionId: SessionId, open: boolean): void { + this.manager.setSubagentCatalogOpen(parentSessionId, open) + } + + /** + * Refresh one direct-child catalog. + * @param parentSessionId - catalog owner. + */ + refreshSubagents(parentSessionId: SessionId): Promise { + return this.manager.refreshSubagents(parentSessionId) + } + + /** + * 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() + } + + /** + * Search the Host's visible message-content index. Results stay + * request-local; the list snapshot remains the metadata authority. + * @param query - non-blank literal phrase. + * @param signal - cancellation for a superseded search. + * @returns bounded results or a business/transport error. + */ + search( + query: string, + signal: AbortSignal, + ): Promise> { + return this.manager.search(query, signal) + } + + /** + * Apply one Session Controller live-control frame. + * @param frame - baseline or live control replacement. + */ + handleControlFrame(frame: Parameters[0]): void { + this.manager.handleControlFrame(frame) + } + + /** + * Apply one remotely forwarded Session-list addition. + * @param summary - current Host summary for the added Session. + */ + handleSessionAdded(summary: Parameters[0]): void { + this.manager.handleSessionAdded(summary) + } + + /** + * Apply one remotely forwarded Session removal. + * @param sessionId - removed Session identity. + */ + handleSessionRemoved(sessionId: Parameters[0]): void { + this.manager.handleSessionRemoved(sessionId) + } + + /** + * Apply one remotely forwarded running-state change. + * @param args - Session identity and current Agent running state. + */ + handleSessionStatus(...args: Parameters): void { + this.manager.handleSessionStatus(...args) + } + + /** + * Apply one remotely forwarded list-activity change. + * @param args - Session identity and durable activity timestamp. + */ + handleSessionActivity(...args: Parameters): void { + this.manager.handleSessionActivity(...args) + } + + /** + * Apply one remotely forwarded Agent failure. + * @param args - Session identity and caller-visible failure description. + */ + handleSessionError(...args: Parameters): void { + this.manager.handleSessionError(...args) + } + + /** 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 ClientSessions.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 + } + + /** + * Fork a session from a completed-turn prefix of the source (same + * synchronous-addressability guarantee as {@link ClientSessions.create}: + * on resolution the child is in the list store and open() can target it). + * @param opts - source session id, the optional event seq anchoring the + * cut (the boundary is the first turn/end at or after it; an in-log + * anchor in an open turn is unavailable rather than clipped backward), + * and whether to increment an inherited durable title before resolving. + * A fractional anchor floors to a real event seq: the frozen nodes of an + * interrupted turn carry flow-ordering seqs between two events, and the + * wire takes integers only. + * @returns the child session id. + * @throws {SessionForkError} with the source id. + * @throws {Error} when a requested child-title rename fails after creation. + */ + async fork(opts: { + sessionId: SessionId + atSeq?: number + increaseTitle?: boolean + }): Promise { + const sourceTitle = opts.increaseTitle + ? this.list.getSnapshot().byId[opts.sessionId]?.title + : undefined + const result = await this.manager.fork({ + sessionId: opts.sessionId, + // Flooring lands inside the anchor's own turn (every turn opens with a + // turn/start), so the host's first-turn/end-at-or-after cut still ends + // on that turn — never clipped back to the previous one. + ...(opts.atSeq === undefined ? {} : { atSeq: Math.floor(opts.atSeq) }), + }) + if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) + this.projectList() + const childId = result.value.sessionId + if (sourceTitle !== undefined) { + const child = this.binding(childId)?.session + if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`) + const renamed = await child.rename(increasedForkTitle(sourceTitle)) + if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`) + } + return childId + } + + /** + * 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): AgentContext | undefined { + return this.resolve(id)?.ctx + } + + /** + * Materialize the Agent scope named by a validated Host Remote Event. + * The first successful Session-list baseline becomes authoritative for its + * lifetime; until then, transport streams may address the scope in either + * arrival order. + * @param id - Host-projected Agent identity (the matching Session id). + * @returns the identity-stable Agent Context. + */ + resolveAgentScope(id: SessionId): AgentContext { + return (this.scopes.get(id) ?? this.materializeScope(id)).ctx + } + + /** + * Read the Agent scope tag off a context. Service-method boundary: 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 boundary as + * {@link ClientSessions.scopeOf}. + * @param ctx - an Agent-scoped context. + * @returns the session face, or undefined when the ctx is untagged or its scope was pruned. + */ + sessionOf(ctx: Context): SessionFace | 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 + } + + /** + * 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.session.open() + void this.manager.refreshSubagents(current) + } + } + + /** + * Lazily mint the scope + binding for an eligible session. Eligibility and + * prune share one predicate: listed on the host or selected + * through a retained subagent address. Breadcrumb-only ancestors remain + * summary data and do not keep scopes alive. + */ + private resolve(id: SessionId): ScopeRecord | undefined { + const existing = this.scopes.get(id) + if (existing !== undefined) return existing + if (!this.eligible(id)) return undefined + return this.materializeScope(id) + } + + /** Materialize one scope after its caller establishes that the id may be addressed. */ + private materializeScope(id: SessionId): ScopeRecord { + 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, eventSource: session.eventSource, ctx } + const record: ScopeRecord = { + fiber, + ctx, + binding, + session, + } + this.scopes.set(id, record) + return record + } + + /** The one aliveness predicate shared by scope mint and prune: host-listed or currently addressed. */ + private eligible(id: SessionId): boolean { + const { ids, current } = this.list.getSnapshot() + return current === id || ids.includes(id) + } + + /** Project the manager's list snapshot into the store (title derivation is display-only). */ + private projectList(): void { + const { + items, current, phase, subagentsByParent, jobsBySession, currentAddress, + } = 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, + ...(entry.completed ? { completed: true } : {}), + blank: entry.blank, + updatedAt: entry.updatedAt, + ...(entry.projectionValues === undefined + ? {} + : { projectionValues: entry.projectionValues }), + ...(entry.title !== undefined ? { title: entry.title } : {}), + ...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}), + ...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}), + ...(entry.origin !== undefined ? { origin: entry.origin } : {}), + } + } + if (current !== undefined && currentAddress !== undefined) { + const seen = new Set() + let address: SubagentAddress | undefined = currentAddress + while (address !== undefined && !seen.has(address.childSessionId)) { + const childId = address.childSessionId + seen.add(childId) + const child = subagentsByParent[address.parentSessionId]?.entries + .find(entry => entry.kind === 'child' && entry.id === childId) + if (child?.kind !== 'child') break + const displayTitle = child.label ?? childId + const summary = byId[childId] + if (summary === undefined) { + byId[childId] = { + id: childId, + displayTitle, + parentId: address.parentSessionId, + origin: 'subagent', + running: child.activity === 'running', + blank: false, + updatedAt: 0, + } + } else if (summary.displayTitle !== displayTitle) { + byId[childId] = { ...summary, displayTitle } + } + const parent = byId[address.parentSessionId] + if (parent !== undefined && parent.origin !== 'subagent') break + address = this.manager.navigationAddress(address.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.getSnapshot().subagentAddress?.childSessionId !== currentAddress?.childSessionId + || this.selection.getSnapshot().subagentAddress?.parentSessionId !== currentAddress?.parentSessionId + || this.selection.getSnapshot().subagentAddress?.mode !== currentAddress?.mode)) { + this.selection.set({ + sessionId: current, + ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), + }) + } + this.list.set({ ids, byId, current, phase, subagentsByParent, jobsBySession, currentAddress }) + this.pruneScopes() + } + + /** Tear down scope + instance for no-longer-eligible sessions off stage; the staged one defers until the stage moves. */ + private pruneScopes(): void { + if (this.list.getSnapshot().phase === 'pending') return + 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.startScopeDrop(id, record) + } + } + + private startScopeDrop(id: SessionId, record: ScopeRecord): void { + const drop = this.dropScope(id, record) + this.scopeDrops.add(drop) + void drop.then( + () => { this.scopeDrops.delete(drop) }, + () => { this.scopeDrops.delete(drop) }, + ) + } + + private async drainScopeDrops(): Promise { + while (this.scopeDrops.size > 0) { + await Promise.allSettled([...this.scopeDrops]) + } + } + + /** + * One teardown for the whole per-session axis: the scope + * fiber (cascading every actx-registered effect: input shell, slash + * controller, popup, plugin stores, listeners), the session-keyed slot + * registrations and the Session instance itself — the host session log is the + * durable truth, a reopen lazily rebuilds and backfills via open(). + */ + private async dropScope(id: SessionId, record: ScopeRecord): Promise { + // Release the Session's dispatch point with the scope it belongs to (a + // surviving instance — the live Intent — rebinds when resolve re-mints). + record.session.unbindScope() + await Promise.allSettled([ + record.fiber.dispose(), + 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.startScopeDrop(id, record) + } + } + } +} diff --git a/packages/api/session-controller/src/client/sessions/session.ts b/packages/api/session-controller/src/client/sessions/session.ts new file mode 100644 index 0000000000..db7cf9a997 --- /dev/null +++ b/packages/api/session-controller/src/client/sessions/session.ts @@ -0,0 +1,662 @@ +// Sessions remain resident after creation so their open Remote sources keep running off-screen. + +import type { Context } from '@deepseek-ai/cordis' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' +import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SubagentAddress } from '@deepseek-ai/dsh-subagent/client' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { + SessionEventStream, + sessionStreamFailure, +} from '../transport.ts' +import type { SessionJournalChange } from '../transport.ts' +import type { + PromptContentPart, + QueueAction, + SessionAddress, + SessionControlFrame, + SessionQueuedItem, + SessionRequestId, + SessionError, +} from '../../types.ts' +import type { ClientFailure, ClientResult } from '../contract/result.ts' +import { transportResult } from '../contract/result.ts' +import type { SessionFace } from '../contract/session.ts' +import type { + OpenState, PromptError, SessionSnapshot, +} from '../contract/snapshot.ts' +import { MutableSessionEventSource } from '../contract/events.ts' +import type { + SessionEventLikeEntry, SessionLiveEventEntry, +} from '../contract/events.ts' +import { Notifier } from './notifier.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionRemotes } from './remotes.ts' +import { ProjectionValueStore } from './projection-store.ts' +import type { ProjectionsBaseline } from './projection-store.ts' +import { resolvedClientTimeZone } from '../time-zone.ts' +import { SessionQueueMirror } from './queue-mirror.ts' + +/** Messages requested per history page. */ +export const PAGE_MESSAGES = 50 + +/** Manager-owned observers of a Session object's local state edges. */ +export interface SessionOptions { + /** Catalog-discovered address selecting non-activating subagent transport. */ + address?: SubagentAddress + /** Whether the exact direct parent Agent was live at the latest catalog read; absent before that read. */ + parentAvailable?: boolean + /** + * First ACCEPTED prompt on a blank session (fires at most once, on the + * prompt RPC's success response): the manager mirrors the blank→false flip + * into its list row so the session surfaces without waiting for a host + * frame. Acceptance is the flip point because it proves the user message + * is in the host log; a rejected first prompt keeps the session blank + * (hidden, still reusable by connectWorkspace). + */ + onEngaged?(session: Session): void + /** + * Manager-owned projection value store to adopt (frames route through the + * manager and values outlive instantiation); omitted, the Session owns a + * private store (bare object-layer construction). + */ + projections?: ProjectionValueStore +} + +/** + * Owns a session's event window, lifecycle state, and observable + * snapshot. React bindings remain outside this data layer. Features see only + * the {@link SessionFace} slice (ISession verbs + the snapshot source); the + * remaining public members are Session Controller internals. + */ +export class Session implements SessionFace { + // ---- Window and derived state (all private; the snapshot is the only read API) ---- + private baseSeq = 0 + private hasMore = false + private openState: OpenState = 'cold' + private openError: ClientFailure | null = null + private openPromise: Promise | null = null + /** Bumped by stream replacement to invalidate an in-flight doOpen. Stale + * passes drop all writes once the generation moves on. */ + private openGeneration = 0 + private loadingOlder = false + /** Authoritative stream-only inbox snapshot; pending work never hits history. */ + private readonly queueMirror = new SessionQueueMirror() + private running = false + private address: SubagentAddress | undefined + private parentAvailable: boolean | undefined + /** + * Sticky send marker, private input of the composerPhase derivation: set + * synchronously before prompt()'s first await, never reset — the blank → + * engaging edge of the phase machine (see ComposerPhase). + */ + private promptAttempted = false + /** A first accepted prompt stays in the engaging phase until its turn is observable. */ + private firstPromptPendingTurn = false + /** Empty-log mirror (see ConversationSnapshot.blank); unknown bare sessions begin conservatively blank. */ + private blankBit = true + private removed = false + private promptError: PromptError | null = null + private lastAgentError: string | null = null + /** Owns the addressed page/follow lifecycle while this Session is open. */ + private events: SessionEventStream | undefined + + /** + * Per-session projection value store (push model; see the session-projection + * subsystem page, docs/subsystems/session-projection.md): finished whole + * values computed on the Host, seeded by the tail page's + * projections block and updated by Session Controller control frames under the + * one higher-seq-wins rule. Keys are read via `projections.faceOf(key)` + * (the useProjection resolution face); the conversation snapshot never + * carries projection values, and no client-side domain folding exists. + * Manager-owned when constructed through SessionManager (frames route and + * the store outlives instantiation, the title-snapshot precedent); a bare + * construction gets a private store. + */ + readonly projections: ProjectionValueStore + + /** Contiguous history and live tail consumed by Conversation assembly. */ + readonly eventSource = new MutableSessionEventSource() + private snapshotCache: SessionSnapshot + private readonly notifier: Notifier + /** + * Agent-scoped cordis context, bound once by ClientSessions when it + * mints the scope (the client mirror of the host Agent's loopCtx). The + * Session dispatches its own scoped events through it; undefined means + * unbound (bare object-layer construction) or already pruned — both skip + * dispatch-dependent behavior rather than fail. + */ + private actx: Context | undefined + + /** + * @param sessionId - Host session identity (client sessions are always Host-born). + * @param remote - generated Remote namespaces this session calls. + * @param options - optional manager-owned state observers. + */ + constructor( + readonly sessionId: SessionId, + private readonly remote: SessionRemotes, + private readonly options: SessionOptions = {}, + ) { + this.projections = options.projections ?? new ProjectionValueStore() + this.address = options.address + this.parentAvailable = options.parentAvailable + this.notifier = new Notifier(() => { + this.snapshotCache = this.buildSnapshot() + }) + this.snapshotCache = this.buildSnapshot() + } + + /** + * Bind the Agent-scoped context minted by ClientSessions (single write; + * a second bind is a wiring error and throws). Direction stays one-way at + * this binding boundary: consumers still reach the Session via `sessions.sessionOf`, + * while the Session holds its own dispatch point (host Agent.loopCtx + * mirror). + * @param actx - the agent's scoped context. + */ + bindScope(actx: Context): void { + if (this.actx !== undefined) throw new Error(`session ${this.sessionId} already has a bound scope`) + this.actx = actx + } + + /** Release the bound scope at prune time (a later rebind accompanies a freshly minted scope). */ + unbindScope(): void { + this.actx = undefined + } + + // ---- Operations ---- + + /** + * Send (queue/steer passed through 1:1); failures land in the snapshot's promptError. + * @param content - text plus browser-owned temporary image uploads. + * @param mode - queue appends after the current turn; steer interrupts it. + * @returns the prompt result (also mirrored into promptError on failure). + */ + async prompt( + content: PromptContentPart[], + mode: 'queue' | 'steer', + signal?: AbortSignal, + ): Promise> { + this.promptError = null + this.lastAgentError = null + // Synchronous, before the first await: the blank → engaging edge must be + // visible on the session area's very first frame when a caller sends + // ahead of navigation (first-send flow). + this.promptAttempted = true + if (this.blankBit) this.firstPromptPendingTurn = true + this.notifier.markDirty() + let result: ClientResult<{ accepted: true }> + try { + if (this.address === undefined) { + const clientTimeZone = resolvedClientTimeZone() + result = toSessionResult(await this.remote.session.prompt({ + requestId: randomUUID() as SessionRequestId, + sessionId: this.sessionId, + mode, + content, + clientTimeZone, + }, signal)) + } else if (this.address.mode === 'one-shot') { + result = { + ok: false, + error: { + code: 'subagent-not-resumable', + message: 'one-shot subagent conversations are read-only', + details: { childSessionId: this.address.childSessionId }, + }, + } + } else { + if (content.some(part => part.type === 'image')) { + result = { + ok: false, + error: { + code: 'attachment-error', + message: 'Image input is unavailable for subagent continuations.', + details: { reason: 'SUBAGENT_IMAGE_UNSUPPORTED' }, + }, + } + } else { + const routed = toSessionResult(await this.remote.subagents.prompt({ + requestId: randomUUID() as SessionRequestId, + parentSessionId: this.address.parentSessionId, + childSessionId: this.address.childSessionId, + mode: this.address.mode, + content: content.flatMap(part => part.type === 'text' + ? [{ type: 'text' as const, text: part.text }] + : []), + clientTimeZone: resolvedClientTimeZone(), + }, signal)) + result = routed.ok ? { ok: true, value: { accepted: true } } : routed + } + } + } catch (error) { + result = transportResult(error) + } + if (!result.ok) { + this.promptError = { op: 'send', error: result.error } + this.notifier.markDirty() + return result + } + // Blank flips on ACCEPTANCE, not attempt: an accepted prompt starts the + // conversation's first turn on the host (the host criterion — a logged + // turn/start — is fact, not optimism; standalone command and projection + // events never flip it), while a rejected first prompt must keep the + // session blank — the client-side blank mirror only ever lowers, so + // flipping early on a failure would surface the session forever and + // strip its connectWorkspace reuse eligibility against the host's + // authority. + if (this.blankBit) { + this.blankBit = false + this.options.onEngaged?.(this) + this.notifier.markDirty() + } + return result + } + + /** + * Resolve one image referenced by this session into browser-consumable bytes. + * @param attachmentId - opaque id found in the folded session log. + * @returns the authenticated reference and decoded bytes. + */ + async readAttachment( + attachmentId: AttachmentIdType, + ): Promise> { + try { + const result = await this.remote.session.attachment({ + sessionId: this.sessionId, + attachmentId, + }) + if (!result.ok) return toSessionResult(result) + const binary = atob(result.value.data) + const data = Uint8Array.from(binary, char => char.charCodeAt(0)) + return { ok: true, value: { attachment: result.value.attachment, data } } + } catch (error) { + return transportResult(error) + } + } + + /** Apply one operation to a still-pending queue occurrence. */ + async updateQueue(itemId: MessageId, action: QueueAction): Promise> { + try { + return toSessionResult(await this.remote.session.updateQueue({ sessionId: this.sessionId, itemId, action })) + } catch (error) { + return transportResult(error) + } + } + + /** + * Stop the active turn while the Host preserves pending inbox work; failures + * land in promptError (same error-strip display slot). A continuable + * subagent address routes through `subagents.interruptByParent`, whose durable + * parent-address authority works without a live parent Agent; a one-shot + * address stays uncancellable (the UI offers no stop action, so this arm is + * defensive). + * @returns the cancel result. + */ + async cancel(): Promise> { + const address = this.address + if (address !== undefined && address.mode === 'one-shot') { + const result: ClientResult<{ accepted: true }> = { + ok: false, + error: { + code: 'subagent-delivery-unavailable', + message: 'subagent activation cancellation is unavailable', + details: { childSessionId: address.childSessionId }, + }, + } + this.promptError = { op: 'stop', error: result.error } + this.notifier.markDirty() + return result + } + let result: ClientResult<{ accepted: true }> + try { + result = address !== undefined + ? toSessionResult(await this.remote.subagents.interruptByParent( + address.childSessionId, + address.parentSessionId, + address.mode, + )) + : toSessionResult(await this.remote.session.cancel({ sessionId: this.sessionId })) + } catch (error) { + result = transportResult(error) + } + if (!result.ok) { + this.promptError = { op: 'stop', error: result.error } + this.notifier.markDirty() + } + return result + } + + /** + * Rename: contract session.rename 1:1. On success settle the 'title' + * projection cell from the response's `{title, seq}` under the store's + * higher-seq-wins rule (the push frame arriving later is a no-op replay), + * so the list row and any useProjection('title') reader update without + * waiting for the control-stream projection update. + * @param title - raw title text (the host normalizes acceptance). + * @returns the rename result (normalized accepted title + title event seq). + */ + async rename(title: string): Promise> { + try { + const result = toSessionResult(await this.remote.session.rename({ sessionId: this.sessionId, title })) + if (result.ok) this.projections.apply('title', result.value.title, result.value.seq) + return result + } catch (error) { + return transportResult(error) + } + } + + /** + * Execute one slash-command line against this session's agent — pure + * admission semantics (the host executor durably logs the lifecycle; + * outcomes render as flow nodes, never as a response echo). + * @param line - the full command line, leading slash included. + * @returns the admission result, or the error branch on transport failure. + */ + async command(line: string): Promise> { + const result = await this.remote.commands.execute(this.sessionId, line, []) + if (!result.ok) return result + return { ok: true, value: { matched: result.value !== undefined } } + } + + /** First open: pull the tail page (idempotent — in-flight/already-open returns the existing promise). */ + open(): Promise { + if (this.openState === 'open') return Promise.resolve() + if (this.openPromise !== null) return this.openPromise + const promise = this.doOpen(this.openGeneration).finally(() => { + // Identity-guarded: a superseded open must not null out the promise resync just started. + if (this.openPromise === promise) this.openPromise = null + }) + this.openPromise = promise + return promise + } + + /** Page up: pull one earlier page with the window's first seq as beforeSeq and prepend. */ + async loadOlder(): Promise { + if (this.openState !== 'open' || !this.hasMore || this.loadingOlder) return + const events = this.events + if (events === undefined) return + this.loadingOlder = true + this.notifier.markDirty() + try { + await events.prepend({ beforeSeq: this.baseSeq, maxMessages: PAGE_MESSAGES }) + } catch (error) { + if (sessionStreamFailure(error) === undefined) { + console.error('[session-controller] loadOlder failed:', error) + } + } finally { + this.loadingOlder = false + this.notifier.markDirty() + } + } + + /** Rebuild an opened history source after address replacement. + * Invalidates any in-flight open first; queue state belongs to the independently + * reconnecting control stream and remains untouched. */ + async resync(): Promise { + if (this.openState === 'cold') return // never opened: no window to rebuild (doOpen flips to 'loading' synchronously, so cold implies no in-flight open) + this.openGeneration++ + const events = this.events + this.events = undefined + await events?.dispose() + this.openPromise = null + this.openState = 'cold' + this.openError = null + this.baseSeq = 0 + this.notifier.markDirty() + await this.open() + } + + // ---- Subscription API (useSyncExternalStore direct wiring) ---- + + /** + * uSES subscription entry. + * @param listener - change callback. + * @returns the unsubscribe function. + */ + subscribe(listener: () => void): () => void { + return this.notifier.subscribe(listener) + } + + /** + * Cached Session snapshot (rebuilt lazily when dirty with no listeners). + * @returns the cached reference (stable until the next flush). + */ + getSnapshot(): SessionSnapshot { + this.notifier.ensureFresh() + return this.snapshotCache + } + + // ---- Manager-only entry points (@internal; never called by the UI) ---- + + /** + * Replace every transient control value for this Session from one stream baseline. + * @param queue - complete pending queue for this Session. + */ + replaceControl(queue: readonly SessionQueuedItem[]): void { + this.queueMirror.replace(queue) + this.notifier.markDirty() + } + + /** + * Apply one Session-addressed live control update. + * @param frame - queue replacement addressed to this Session. + */ + handleControlFrame(frame: Extract): void { + this.queueMirror.replace(frame.items) + this.notifier.markDirty() + } + + /** + * Running-bit relay from the host stream (list entry and snapshot stay consistent). + * @param running - the new running state. + */ + handleRunning(running: boolean): void { + // Turn-start conversion: a blank session never runs, so the first + // running:true proves another side's first message landed. + if (running && this.blankBit) { + this.blankBit = false + this.notifier.markDirty() + } + if (running) this.firstPromptPendingTurn = false + if (this.running === running) return + this.running = running + this.notifier.markDirty() + } + + /** + * Install or clear the catalog-discovered transport address. A changed + * address rebuilds an already-open window through its new history route. + * @param address - direct parent/child address, or undefined for ordinary transport. + * @param parentAvailable - latest exact-parent availability hint, or undefined before a catalog read. + */ + configureSubagent(address: SubagentAddress | undefined, parentAvailable?: boolean): void { + const same = this.address?.parentSessionId === address?.parentSessionId + && this.address?.childSessionId === address?.childSessionId + && this.address?.mode === address?.mode + this.address = address + this.parentAvailable = parentAvailable + if (!same && this.openState !== 'cold') void this.resync() + else this.notifier.markDirty() + } + + /** + * Update only the parent availability hint from a catalog refresh. + * @param available - whether the exact direct parent is live. + */ + handleSubagentParentAvailable(available: boolean): void { + if (this.parentAvailable === available) return + this.parentAvailable = available + this.notifier.markDirty() + } + + /** + * Blank-bit relay from the authoritative summary source (`session.list` and + * `api-session/added`). Monotone: once any signal (local first send, + * running flip, an earlier summary) cleared it, a stale true never + * re-blanks. + * @param blank - the summary's derived empty-log bit. + */ + handleBlank(blank: boolean): void { + if (blank === this.blankBit) return + if (blank && (this.promptAttempted || this.running)) return + this.blankBit = blank + this.notifier.markDirty() + } + + /** `api-session/removed` relay: flag the snapshot while retaining the resident instance. */ + handleRemoved(): void { + this.removed = true + this.notifier.markDirty() + } + + /** + * `api-session/error` relay: the outlet for live failures with no turn position. + * @param message - the stringified error. + */ + handleAgentError(message: string): void { + this.lastAgentError = message + this.notifier.markDirty() + } + + /** + * Stop the Session's live Remote source. + * @returns when the Remote iterator has completed teardown. + */ + async dispose(): Promise { + this.openGeneration++ + const events = this.events + this.events = undefined + await events?.dispose() + } + + // ---- Private ---- + + /** @param generation - openGeneration at launch; stale passes cannot publish after replacement. */ + private async doOpen(generation: number): Promise { + this.openState = 'loading' + this.openError = null + this.notifier.markDirty() + const events = new SessionEventStream(this.remote, this.sessionAddress(), { + publish: (change) => { + if (generation !== this.openGeneration || this.events !== events) return + this.acceptEventChange(change) + }, + failed: (error) => { + this.failEventStream(events, generation, error) + }, + }) + this.events = events + try { + await events.open({ maxMessages: PAGE_MESSAGES }) + if (generation !== this.openGeneration || this.events !== events) return + this.openState = 'open' + } catch (error) { + if (generation !== this.openGeneration || this.events !== events) return + this.events = undefined + this.openState = 'error' + this.openError = openFailure(error) + } finally { + if (generation === this.openGeneration) this.notifier.markDirty() + } + } + + /** Apply one contiguous journal update already reconciled by the Remote stream. */ + private acceptEventChange(change: SessionJournalChange): void { + switch (change.type) { + case 'replace': + this.installWindow(change.entries, change.hasMore, change.page.projections) + return + case 'prepend': + this.prependWindow(change.entries, change.hasMore) + return + case 'append': + if (this.appendLive(change.entry)) this.notifier.markDirty() + } + } + + /** Replace the complete contiguous window and apply page-owned projection metadata. */ + private installWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean, projections?: ProjectionsBaseline): void { + this.baseSeq = entries[0]?.event.seq ?? 0 + this.hasMore = hasMore + if (entries.some(entry => entry.event.type === 'turn/start')) this.firstPromptPendingTurn = false + if (projections !== undefined) this.projections.seed(projections) + this.eventSource.replace(entries, hasMore) + this.notifier.markDirty() + } + + /** Prepend one stream-validated history page. */ + private prependWindow(entries: readonly SessionEventLikeEntry[], hasMore: boolean): void { + this.baseSeq = entries[0]?.event.seq ?? this.baseSeq + this.hasMore = hasMore + this.eventSource.prepend(entries, hasMore) + } + + /** Append one stream-validated live event. */ + private appendLive(entry: SessionLiveEventEntry): boolean { + const event = entry.event + const awaitingFirstTurn = this.firstPromptPendingTurn + if (event.type === 'turn/start') this.firstPromptPendingTurn = false + const queueChanged = this.queueMirror.acceptDurable(event) + this.eventSource.append(entry) + return queueChanged || awaitingFirstTurn !== this.firstPromptPendingTurn + } + + /** Publish a terminal background failure only while this stream still owns the Session. */ + private failEventStream(events: SessionEventStream, generation: number, error: unknown): void { + if (generation !== this.openGeneration || this.events !== events) return + this.openGeneration++ + this.events = undefined + this.openPromise = null + this.openState = 'error' + this.openError = openFailure(error) + void events.dispose() + this.notifier.markDirty() + } + + private buildSnapshot(): SessionSnapshot { + return { + sessionId: this.sessionId, + queue: this.queueMirror.snapshot(), + running: this.running, + subagent: this.address === undefined + ? null + : { + address: this.address, + ...(this.parentAvailable === undefined ? {} : { parentAvailable: this.parentAvailable }), + }, + removed: this.removed, + openState: this.openState, + openError: this.openError, + hasMore: this.hasMore, + loadingOlder: this.loadingOlder, + promptError: this.promptError, + blank: this.blankBit, + lastAgentError: this.lastAgentError, + promptAttempted: this.promptAttempted, + awaitingFirstTurn: this.firstPromptPendingTurn, + } + } + + private sessionAddress(): SessionAddress { + return this.address === undefined + ? { kind: 'session', sessionId: this.sessionId } + : { kind: 'subagent', ...this.address } + } +} + +/** Convert a terminal Session stream failure to the Client error vocabulary. */ +function openFailure(error: unknown): ClientFailure { + const failure = sessionStreamFailure(error) + if (failure !== undefined) return failure as SessionError + const folded = transportResult(error) + /* v8 ignore next -- transportResult never returns an ok result. */ + if (folded.ok) throw new Error('transportResult returned an unexpected success') + return folded.error +} +/** Narrow a generated Session Remote failure to its service-owned error vocabulary. */ +function toSessionResult(result: RemoteResult): ClientResult { + return result.ok ? result : { ok: false, error: result.error as SessionError } +} diff --git a/packages/client/runtime/src/client/time-zone.ts b/packages/api/session-controller/src/client/time-zone.ts similarity index 100% rename from packages/client/runtime/src/client/time-zone.ts rename to packages/api/session-controller/src/client/time-zone.ts diff --git a/packages/api/session-controller/src/client/transport.ts b/packages/api/session-controller/src/client/transport.ts new file mode 100644 index 0000000000..48298268bb --- /dev/null +++ b/packages/api/session-controller/src/client/transport.ts @@ -0,0 +1,227 @@ +/** Session-specific adapters for Gateway-owned Remote stream lifecycles. */ + +import type {} from '@deepseek-ai/dsh-api-session-controller/remote' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteJournalStream, + RemoteSnapshotStream, + RemoteStreamCarrierError, + RemoteStreamError, + type ClientRemote, + type RemoteJournalChange, + type RemoteJournalFrame, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { + SessionAddress, + SessionControlFrame, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, +} from '../types.ts' +import { + historyEntries, + historyRecordFirstSeq, + historyRecordLastSeq, +} from './sessions/history-records.ts' +import type { SessionEventLikeEntry, SessionLiveEventEntry } from './contract/events.ts' + +export { + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, +} from '../types.ts' + +/** Pagination fields bound to an already-addressed Session journal. */ +export type ClientSessionPageRequest = Omit + +/** Complete generated `ctx.remote.session` namespace. */ +export type SessionRemote = ClientRemote['session'] + +/** Opening metadata carried only by a follow snapshot, never by loadOlder pages. */ +interface SessionJournalPage extends SessionPage { + readonly projections?: SessionProjectionBaseline +} + +/** One complete publication from the Session journal stream. */ +export type SessionJournalChange = + | { + readonly type: 'replace' | 'prepend' + readonly page: SessionJournalPage + readonly entries: readonly SessionEventLikeEntry[] + readonly hasMore: boolean + } + | { readonly type: 'append'; readonly entry: SessionLiveEventEntry } + +function toSessionJournalChange( + change: RemoteJournalChange, +): SessionJournalChange { + switch (change.type) { + case 'replace': + case 'prepend': + return { ...change, entries: historyEntries(change.entries) } + case 'append': { + if (change.entry.type !== 'event') { + throw new Error('session live stream emitted a packed history record') + } + return { + type: 'append', + entry: change.entry as unknown as SessionLiveEventEntry, + } + } + } +} + +type SessionControlBaselineFrame = Extract +type SessionControlDeltaFrame = Exclude + +/** Gateway-owned control snapshot stream configured for Session frames. */ +export type SessionControlStream = RemoteSnapshotStream< + SessionControlBaselineFrame, + SessionControlDeltaFrame +> + +type SessionStreamRemote = Pick + +/** Domain sinks used by the Host-wide Session control stream. */ +export interface SessionControlStreamOptions { + /** Apply a complete baseline or one later update. */ + readonly accept: (frame: SessionControlFrame) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** Domain sinks used by one addressed Session event journal. */ +export interface SessionEventStreamOptions { + /** Apply one complete event-window change. */ + readonly publish: (change: SessionJournalChange) => void + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal stream, page, or protocol failure after opening. */ + readonly failed: (error: unknown) => void +} + +/** + * Create the Host-wide Session control snapshot stream. + * @param remote - generated Session namespace and Gateway stream factory. + * @param options - Session state destinations. + * @returns an unstarted stream owned by the Client Session runtime. + */ +export function createSessionControlStream( + remote: SessionStreamRemote, + options: SessionControlStreamOptions, +): SessionControlStream { + const stream = remote.$stream({ + name: 'session control stream', + open: signal => remote.session.control(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('session control stream ended without a terminal result') + : new Error('session control stream ended before its opening snapshot'), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }) + return new RemoteSnapshotStream(stream, { + name: 'session control stream', + isSnapshot: (frame): frame is SessionControlBaselineFrame => frame.type === 'baseline', + replace: options.accept, + update: options.accept, + failed: options.failed, + }) +} + +/** Gateway-owned event journal bound to one ordinary or direct-subagent Session address. */ +export class SessionEventStream extends RemoteJournalStream< + SessionJournalPage, + SessionHistoryRecord, + number, + ClientSessionPageRequest +> { + /** + * @param remote - generated Session namespace and Gateway stream factory. + * @param address - durable ordinary-Session or direct-subagent address. + * @param options - Session event-window destinations. + */ + constructor( + private readonly remote: SessionStreamRemote, + private readonly address: SessionAddress, + options: SessionEventStreamOptions, + ) { + super(remote, { + name: 'session event stream', + emptyCursor: -1, + entries: page => page.records, + hasMore: page => page.hasMore, + first: historyRecordFirstSeq, + last: historyRecordLastSeq, + compare: (left, right) => left - right, + follows: (left, right) => right === left + 1, + publish: (change) => { options.publish(toSessionJournalChange(change)) }, + ...(options.carrierFailed === undefined + ? {} + : { carrierFailed: options.carrierFailed }), + failed: options.failed, + }) + } + + /** @inheritdoc */ + protected override async * follow( + request: ClientSessionPageRequest, + signal: AbortSignal, + ): AsyncIterable> { + for await (const frame of this.remote.session.follow({ + address: this.address, + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + }, signal)) { + if (frame.type === 'snapshot') { + yield { + type: 'opened', + cursor: frame.cursor, + page: { + records: frame.records, + hasMore: frame.hasMore, + projections: frame.projections, + }, + } + continue + } + yield { type: 'entry', entry: frame } + } + } + + /** @inheritdoc */ + protected override async readPage( + request: ClientSessionPageRequest, + throughSeq: number, + signal: AbortSignal, + ): Promise { + const result = await this.remote.session.page( + { address: this.address, throughSeq, ...request }, + signal, + ) + if (!result.ok) { + throw new RemoteStreamError( + result.error.code, + result.error.message, + result.error.details, + ) + } + return result.value + } + + /** @inheritdoc */ + protected override repairRequest( + request: ClientSessionPageRequest, + ): ClientSessionPageRequest { + return request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages } + } +} + +/** + * Recover a Host Session failure from a Remote stream terminal error. + * @param error - value thrown while opening or consuming a Session stream. + * @returns the Host failure, or `undefined` for carrier and local failures. + */ +export function sessionStreamFailure(error: unknown): RemoteFailure | undefined { + if (!(error instanceof RemoteStreamError)) return undefined + return { code: error.code, message: error.message, details: error.details } +} diff --git a/packages/api/session-controller/src/commands.ts b/packages/api/session-controller/src/commands.ts new file mode 100644 index 0000000000..48c41f3aa1 --- /dev/null +++ b/packages/api/session-controller/src/commands.ts @@ -0,0 +1,597 @@ +/** Session commands whose activation policy is explicit at each Remote method. */ + +import { randomUUID } from 'node:crypto' +import type { Context } from '@deepseek-ai/cordis' +import type { Agent, ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import { PresetMountError, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { + ReasoningEffortId, createUserMessage, freezeMessage, +} from '@deepseek-ai/dsh-llm' +import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader, UserMessage } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import { SessionTitleInvalidError } from '@deepseek-ai/dsh-session-title' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { Workspace } from '@deepseek-ai/dsh-workspace' +import { + ApiSessionAgentController, + ApiSessionCwdConflict, + ApiSessionNotFound, + ApiSessionPresetConflict, + ApiSessionSubagentOwnership, + apiSessionSubagentOwnershipError, + hasApiSessionSubagentOwner, + inspectApiSession, +} from './agent.ts' +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionCreateRequest, + SessionCreateValue, + SessionForkRequest, + SessionForkValue, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from './types.ts' + +interface SessionReadState { + readonly id: SessionId + readonly header: SessionHeader + readonly events: SessionEvent[] +} + +/** Implements Session business commands delegated by the Session Controller Remote service. */ +export class SessionCommandController { + /** + * @param ctx - Host context carrying Agent, model, attachment, title, and Workspace services. + * @param agents - sole owner of create, resume, and Session-local model selection. + * @param defaultCwd - project directory used when create names neither a Workspace nor a cwd. + */ + constructor( + private readonly ctx: Context, + private readonly agents: ApiSessionAgentController, + private readonly defaultCwd: string, + ) {} + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + async create(request: SessionCreateRequest): Promise { + if (request.workspaceId !== undefined && request.cwd !== undefined) { + reject('bad-request', 'session.create accepts workspaceId or cwd, not both', {}) + } + const sessionId = request.sessionId ?? SessionId(`session-${randomUUID()}`) + let workspace: Workspace | undefined + if (request.workspaceId !== undefined) { + workspace = this.ctx.workspaceRegistry.get(request.workspaceId) + if (workspace === undefined) { + reject('workspace-not-found', `workspace "${request.workspaceId}" not found`, { + workspaceId: request.workspaceId, + }) + } + } + const cwd = workspace?.path ?? request.cwd ?? this.defaultCwd + let adopted: Agent + try { + adopted = await this.agents.ensureSession( + sessionId, + cwd, + request.sessionId !== undefined, + request.agentPreset, + ) + } catch (error) { + this.rejectCreation(sessionId, error) + } + if (workspace !== undefined) { + try { + await workspace.attachSession(sessionId) + } catch (error) { + reject( + 'workspace-attach-failed', + `session "${sessionId}" was created but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId, workspaceId: workspace.id }, + ) + } + } + const agentPreset = this.agents.presetForSession(adopted.session) + return { sessionId, ...(agentPreset === undefined ? {} : { agentPreset }) } + } + + /** + * Validate and install one Session-local model selection. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ + async selectModel(request: SessionSelectModelRequest): Promise { + const agent = await this.resolveAgent(request.sessionId) + return this.agents.serializeImageAdmission(agent, async () => { + try { + const resolved = await this.ctx.llm.resolveCallConfig({ + provider: request.provider, + model: request.model, + ...(request.reasoningEffort === undefined + ? {} + : { reasoningEffort: ReasoningEffortId(request.reasoningEffort) }), + }) + const selected: AgentModelSelection = { + provider: resolved.provider, + model: resolved.model, + ...(resolved.reasoningEffort === undefined + ? {} + : { reasoningEffort: resolved.reasoningEffort }), + } + this.agents.selectForNextRequest(agent, selected) + try { + await this.ctx.agentDefaultModel.saveSelection(selected) + } catch (error) { + this.ctx.logger.warn( + `session-controller: model selection changed for the Session but the default was not saved: ${String(error)}`, + ) + } + return { selected: { ...selected } } + } catch (error) { + if (error instanceof TypertRemoteFailure) throw error + reject( + 'model-unavailable', + error instanceof Error ? error.message : String(error), + { provider: request.provider, model: request.model }, + ) + } + }) + } + + /** + * Normalize and append a user-owned Session title. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + async rename(request: SessionRenameRequest): Promise { + const agent = await this.resolveAgent(request.sessionId) + const titles = this.ctx.get('sessionTitle') + if (titles === undefined) { + reject('internal', 'renaming is unavailable: this deployment mounts no session-title service', {}) + } + try { + const accepted = titles.rename(agent.session, request.title) + return { title: accepted.title, seq: accepted.eventSeq } + } catch (error) { + if (error instanceof SessionTitleInvalidError) { + reject('title-invalid', error.message, { sessionId: request.sessionId }) + } + reject( + 'internal', + `failed to rename session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + } + + /** + * Create a new ordinary Session from one completed-turn prefix. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ + async fork(request: SessionForkRequest): Promise { + if (request.atSeq !== undefined + && (!Number.isInteger(request.atSeq) || request.atSeq < 0)) { + reject('bad-request', 'atSeq must be a non-negative integer', {}) + } + let observed: SessionObservation + try { + observed = await this.ctx.sessionQuery.observeSession(request.sessionId) + } catch (error) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') { + reject('session-not-found', `session "${request.sessionId}" not found`, { + sessionId: request.sessionId, + }) + } + reject( + 'internal', + `fork source unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + using source = observed + const lastSeq = source.events.at(-1)?.seq ?? -1 + const atSeq = request.atSeq + const anchoredBoundary = atSeq === undefined + ? undefined + : source.events.find(event => event.type === 'turn/end' && event.seq >= atSeq) + const boundary = anchoredBoundary + ?? (atSeq === undefined || atSeq > lastSeq + ? source.events.findLast(event => event.type === 'turn/end') + : undefined) + if (boundary === undefined) { + reject( + 'fork-unavailable', + atSeq !== undefined && atSeq <= lastSeq + ? `session "${request.sessionId}" has not completed the turn containing event ${String(atSeq)}` + : `session "${request.sessionId}" has no completed turn to fork from`, + { sessionId: request.sessionId }, + ) + } + let cut = boundary.seq + 1 + while (cut < source.events.length && source.events[cut]?.type !== 'turn/start') cut++ + let workspace: Workspace | undefined + try { + workspace = await this.forkWorkspace(source.header) + } catch (error) { + reject( + 'internal', + `failed to resolve fork workspace for session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + const childId = SessionId(`session-${randomUUID()}`) + const composition = await this.agents.composeAgent(this.agents.presetForObservation(source)) + try { + const { provider, model } = this.ctx.agentDefaultModel.currentSelection() + await this.ctx.agents.create({ + sessionId: childId, + seed: source.events.slice(0, cut), + meta: { + ...(source.header.cwd === undefined ? {} : { cwd: source.header.cwd }), + parentSession: source.header.id, + seedLength: cut, + ...(composition.agentPreset === undefined + ? {} + : { agentPreset: composition.agentPreset }), + }, + agentOptions: { provider, model }, + setup: composition.setup, + }) + } catch (error) { + reject( + 'internal', + `failed to fork session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + if (workspace !== undefined) { + try { + await workspace.attachSession(childId) + } catch (error) { + reject( + 'workspace-attach-failed', + `session "${childId}" was forked but could not attach to workspace "${workspace.id}": ${String(error)}`, + { sessionId: childId, workspaceId: workspace.id }, + ) + } + } + return { sessionId: childId } + } + + /** + * Admit one browser prompt after explicit Agent resume and image validation. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @returns acknowledgement that the Agent accepted the prompt. + */ + async prompt(request: SessionPromptRequest): Promise { + const clientTimeZone = request.clientTimeZone === undefined + ? undefined + : canonicalClientTimeZone(request.clientTimeZone) + if (request.clientTimeZone !== undefined && clientTimeZone === undefined) { + reject( + 'invalid-time-zone', + 'clientTimeZone must be UTC or a valid IANA Area/Location name', + { value: request.clientTimeZone }, + ) + } + const agent = await this.resolveAgent(request.sessionId) + const selection = this.agents.selectionFor(agent).current + if (!routeServed(this.ctx, selection.provider)) { + reject( + 'model-unavailable', + `no adapter serves provider "${selection.provider}"; select a model for this session`, + { provider: selection.provider, model: selection.model }, + ) + } + const source: MessageSource = { + kind: 'user', + rpcId: request.requestId, + ...(clientTimeZone === undefined ? {} : { clientTimeZone }), + } + const hasImage = request.content.some(part => part.type === 'image') + const admit = async (): Promise => { + try { + if (hasImage) { + const current = this.agents.selectionFor(agent).current + const model = await this.ctx.llm.resolveModelInfo(current.provider, current.model) + if (model.inputModalities !== undefined && !model.inputModalities.includes('image')) { + reject( + 'attachment-error', + `Model "${current.model}" does not support image input.`, + { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' }, + ) + } + } + const content = await durablePromptContent(this.ctx, request.content) + const message: UserMessage = createUserMessage({ content, source }) + if (request.mode === 'steer') agent.steer(message) + else agent.followup(message) + } catch (error) { + if (error instanceof TypertRemoteFailure) throw error + if (error instanceof AttachmentError) { + reject('attachment-error', error.message, { reason: error.code }) + } + reject('agent-busy', 'prompt rejected', { reason: String(error) }) + } + return { accepted: true } + } + return hasImage ? this.agents.serializeImageAdmission(agent, admit) : admit() + } + + /** + * Read one durable image after proving the Session log references it. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + async attachment(request: SessionAttachmentRequest): Promise { + let source: SessionReadState + try { + source = await this.readSessionState(request.sessionId) + } catch (error) { + if (error instanceof ApiSessionNotFound) { + reject('session-not-found', error.message, { sessionId: request.sessionId }) + } + reject( + 'internal', + `attachment authorization unavailable for session "${request.sessionId}": ${String(error)}`, + {}, + ) + } + const ref = referencedImage(source.events, String(request.attachmentId)) + if (ref === undefined) { + reject( + 'attachment-error', + 'Image is not referenced by this session.', + { reason: 'ATTACHMENT_NOT_REFERENCED' }, + ) + } + try { + const stored = await this.ctx.attachments.readImage(ref) + return { + attachment: stored.ref, + data: Buffer.from(stored.data).toString('base64'), + } + } catch (error) { + if (error instanceof AttachmentError) { + reject('attachment-error', error.message, { reason: error.code }) + } + reject('internal', 'Unable to read image attachment.', {}) + } + } + + /** + * Mutate one still-pending queue occurrence without resuming a cold Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + if (request.action.kind === 'edit' + && request.action.content.some(block => block.type !== 'text')) { + reject( + 'attachment-error', + 'queue edits accept text content only', + { reason: 'QUEUE_EDIT_NON_TEXT' }, + ) + } + const agent = this.ctx.agents.get(request.sessionId) + if (agent !== undefined && hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + } + if (agent === undefined) { + reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + } + const nextTurn = agent.inbox.nextTurn.find(message => message.id === request.itemId) + const nextStep = agent.inbox.nextStep.find(message => message.id === request.itemId) + const located = nextTurn === undefined + ? nextStep === undefined ? undefined : { target: 'next-step' as const, message: nextStep } + : { target: 'next-turn' as const, message: nextTurn } + if (located === undefined) { + reject('queue-item-not-found', 'queued item is no longer pending', { itemId: request.itemId }) + } + const { target, message } = located + if (request.action.kind === 'steer' && (target !== 'next-turn' || agent.status !== 'running')) { + reject('steer-unavailable', 'current turn no longer accepts steering', { itemId: request.itemId }) + } + if (request.action.kind === 'edit') { + agent.inbox.replace(request.itemId, freezeMessage({ + ...message, + content: [...request.action.content], + })) + } else { + agent.inbox.remove(request.itemId) + if (request.action.kind === 'steer') agent.steer(message) + } + return { accepted: true } + } + + /** + * Cancel one live ordinary Agent while retaining pending inbox work. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + cancel(request: SessionCancelRequest): SessionCancelValue { + const agent = this.ctx.agents.get(request.sessionId) + if (agent === undefined) { + reject( + 'session-not-found', + `session "${request.sessionId}" not found (not attached)`, + { sessionId: request.sessionId }, + ) + } + if (hasApiSessionSubagentOwner(this.ctx, agent.session, agent)) { + rejectFailure(apiSessionSubagentOwnershipError(request.sessionId)) + } + agent.cancel({ kind: 'user' }, { keepInbox: true }) + return { accepted: true } + } + + private async resolveAgent(sessionId: SessionId): Promise { + const found = await this.agents.resolveAgent(sessionId) + if ('error' in found) rejectFailure(found.error) + return found.agent + } + + private rejectCreation(sessionId: SessionId, error: unknown): never { + if (error instanceof ApiSessionPresetConflict) { + reject('agent-preset-conflict', error.message, { + sessionId: error.sessionId, + requestedPreset: error.requestedPreset, + ...(error.existingPreset === undefined ? {} : { existingPreset: error.existingPreset }), + }) + } + if (error instanceof UnknownPresetError) { + reject('agent-preset-not-found', error.message, { + agentPreset: error.presetId, + available: [...error.available], + }) + } + if (error instanceof PresetMountError) { + reject('agent-preset-invalid', error.message, { + agentPreset: error.presetId, + reason: error.reason, + }) + } + if (error instanceof ApiSessionCwdConflict) { + reject('session-conflict', error.message, { + sessionId: error.sessionId, + requestedCwd: error.requestedCwd, + ...(error.existingCwd === undefined ? {} : { existingCwd: error.existingCwd }), + }) + } + if (error instanceof ApiSessionSubagentOwnership) { + rejectFailure(apiSessionSubagentOwnershipError(error.sessionId)) + } + reject('internal', `failed to create session "${sessionId}": ${String(error)}`, {}) + } + + private async readSessionState(sessionId: SessionId): Promise { + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) { + return { id: attached.id, header: attached.header, events: [...attached.events] } + } + const inspected = await inspectApiSession(this.ctx, sessionId) + return { id: inspected.meta.id, header: inspected.meta, events: inspected.events } + } + + private async forkWorkspace(source: SessionHeader): Promise { + const workspaces = this.ctx.workspaceRegistry.list() + const direct = workspaces.find(workspace => workspace.sessionIds.includes(source.id)) + if (direct !== undefined || source.origin !== 'subagent') return direct + const lineage = await this.ctx.sessionQuery.traceSession(source.id) + for (const ancestor of lineage.ancestors) { + const workspace = workspaces.find(candidate => candidate.sessionIds.includes(ancestor.header.id)) + if (workspace !== undefined) return workspace + } + return undefined + } +} + +function rejectFailure(error: { readonly code: string; readonly message: string; readonly details: object }): never { + throw new TypertRemoteFailure(error) +} + +function reject(code: string, message: string, details: object): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +async function durablePromptContent( + ctx: Context, + content: readonly SessionPromptRequest['content'][number][], +): Promise { + if (content.every(part => part.type === 'text')) { + return content.map(part => ({ type: 'text', text: part.text })) + } + const refs = await admitEncodedImages(ctx.attachments, content.filter(part => part.type === 'image')) + let next = 0 + return content.map(part => part.type === 'text' + ? { type: 'text', text: part.text } + // admitEncodedImages returns one reference per image part in order. + : { type: 'image', attachment: refs[next++] as ImageAttachmentRef }) +} + +function imageBlockIn( + content: unknown, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + if (!Array.isArray(content)) return undefined + for (const value of content) { + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { readonly type?: unknown; readonly attachment?: unknown; readonly content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + if (match(ref)) return ref + } + if (block.type === 'tool-result') { + const nested = imageBlockIn(block.content, match) + if (nested !== undefined) return nested + } + } + return undefined +} + +function imageInEvent( + event: SessionEvent, + match: (ref: ImageAttachmentRef) => boolean, +): ImageAttachmentRef | undefined { + const data = event.data as { + readonly content?: unknown + readonly message?: { readonly content?: unknown } + readonly inserted?: readonly { readonly content?: unknown }[] + readonly chunk?: { readonly type?: unknown; readonly block?: unknown } + } + const direct = imageBlockIn(data.content, match) + if (direct !== undefined) return direct + const message = imageBlockIn(data.message?.content, match) + if (message !== undefined) return message + for (const inserted of data.inserted ?? []) { + const found = imageBlockIn(inserted.content, match) + if (found !== undefined) return found + } + return event.type === 'assistant/chunk' && data.chunk?.type === 'block-end' + ? imageBlockIn([data.chunk.block], match) + : undefined +} + +function referencedImage( + events: readonly SessionEvent[], + attachmentId: string, +): ImageAttachmentRef | undefined { + for (const event of events) { + const found = imageInEvent(event, ref => String(ref.attachmentId) === attachmentId) + if (found !== undefined) return found + } + return undefined +} + +const IANA_TIME_ZONE = /^[A-Za-z][A-Za-z0-9_+.-]*(?:\/[A-Za-z0-9_+.-]+)+$/ + +function canonicalClientTimeZone(value: string): string | undefined { + if (value.length === 0 || value.trim() !== value + || (value !== 'UTC' && !IANA_TIME_ZONE.test(value))) return undefined + try { + return new Intl.DateTimeFormat('en-US', { timeZone: value }).resolvedOptions().timeZone + } catch { + return undefined + } +} + +function routeServed(ctx: Context, provider: string): boolean { + return ctx.llm.listProviders().some(entry => entry.id === provider) +} diff --git a/packages/api/session-controller/src/control.ts b/packages/api/session-controller/src/control.ts new file mode 100644 index 0000000000..4068b5536d --- /dev/null +++ b/packages/api/session-controller/src/control.ts @@ -0,0 +1,211 @@ +/** Live Session queue, jobs, and projection state with reconnect baselines. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobSnapshot } from '@deepseek-ai/dsh-jobs' +import type { + JsonValue, Session, SessionEvent, SessionEventMap, SessionId, UserMessage, +} from '@deepseek-ai/dsh-session' +import type { + SessionControlBaseline, + SessionControlFrame, + SessionJob, + SessionProjectionBaseline, + SessionProjectionValues, + SessionQueuedItem, +} from './types.ts' + +/** Owns the Host-wide Session control stream. */ +export class SessionControlController { + private readonly streams = new Set() + + /** @param ctx - Host context carrying live Agent, projection, and jobs services. */ + constructor(private readonly ctx: Context) { + ctx.on('session/event', (session, event) => { this.onSessionEvent(session, event) }) + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.onChanged((session, key, value, seq) => { + this.broadcast({ + type: 'projection', + sessionId: session.id, + key, + value: value as JsonValue, + seq, + }) + }) + }) + ctx.inject(['jobs'], (jobsCtx) => { + jobsCtx.jobs.onJobsChanged((owner) => { this.onJobsChanged(owner) }) + }) + ctx.on('session/created', (session) => { + const jobs = this.jobsFor(this.ctx.agents.get(session.id)) + if (jobs.length > 0) this.broadcast({ type: 'jobs', sessionId: session.id, jobs }) + }) + ctx.effect(() => () => { + for (const stream of this.streams) stream.end() + this.streams.clear() + }, 'session-controller.control') + } + + /** + * Open one generation of Host-wide live control state. + * @param signal - Remote stream cancellation. + * @returns one complete baseline followed by live replacement frames. + */ + async *control(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const queue = new ControlQueue() + this.streams.add(queue) + try { + yield { type: 'baseline', value: this.baseline() } + yield* queue.iterate(signal) + } finally { + this.streams.delete(queue) + queue.end() + } + } + + private baseline(): SessionControlBaseline { + const sessions = this.ctx.sessions.list() + const queues = Object.create(null) as Record + const jobs = Object.create(null) as Record + for (const session of sessions) { + const agent = this.ctx.agents.get(session.id) + queues[session.id] = agent?.session === session ? queueItems(agent) : [] + jobs[session.id] = this.jobsFor(agent) + } + return { + queues, + jobs, + projections: this.projectionBaseline(sessions), + } + } + + private projectionBaseline( + sessions: readonly Session[], + ): Readonly> { + const registry = this.ctx.get('sessionProjections') + const blocks = Object.create(null) as Record + for (const session of sessions) { + const snapshot = registry?.snapshot(session) + blocks[session.id] = snapshot === undefined + ? { asOfSeq: session.seq - 1, values: {} } + : { + asOfSeq: snapshot.asOfSeq, + // Every projection definition validates its value before snapshot publication. + values: snapshot.values as SessionProjectionValues, + } + } + return blocks + } + + private onSessionEvent(session: Session, event: SessionEvent): void { + if (event.type !== 'agent/inbox/spliced') return + const agent = this.ctx.agents.get(session.id) + if (agent?.session !== session) return + this.broadcast({ + type: 'queue', + sessionId: session.id, + items: queueItems(agent, event.data), + }) + } + + private onJobsChanged(owner: Agent | undefined): void { + if (owner !== undefined) { + this.broadcast({ type: 'jobs', sessionId: owner.id, jobs: this.jobsFor(owner) }) + return + } + for (const session of this.ctx.sessions.list()) { + this.broadcast({ + type: 'jobs', + sessionId: session.id, + jobs: this.jobsFor(this.ctx.agents.get(session.id)), + }) + } + } + + private jobsFor(agent: Agent | undefined): SessionJob[] { + const jobs = this.ctx.get('jobs') + return jobs === undefined ? [] : jobs.list(agent).map(jobView) + } + + private broadcast(frame: SessionControlFrame): void { + for (const stream of this.streams) stream.push(frame) + } +} + +class ControlQueue { + private readonly buffer: SessionControlFrame[] = [] + private wake: (() => void) | undefined + private done = false + + push(frame: SessionControlFrame): void { + if (this.done) return + this.buffer.push(frame) + const wake = this.wake + this.wake = undefined + wake?.() + } + + end(): void { + if (this.done) return + this.done = true + const wake = this.wake + this.wake = undefined + wake?.() + } + + async *iterate(signal: AbortSignal): AsyncIterable { + const onAbort = (): void => { this.end() } + signal.addEventListener('abort', onAbort, { once: true }) + try { + while (!this.done && !signal.aborted) { + const frame = this.buffer.shift() + if (frame !== undefined) { + yield frame + continue + } + await new Promise((resolve) => { this.wake = resolve }) + } + while (this.buffer.length > 0 && !signal.aborted) yield this.buffer.shift() as SessionControlFrame + } finally { + signal.removeEventListener('abort', onAbort) + this.end() + } + } +} + +function queueItems( + agent: Agent, + splice?: SessionEventMap['agent/inbox/spliced'], +): SessionQueuedItem[] { + const project = (target: 'next-turn' | 'next-step'): readonly UserMessage[] => { + const messages = target === 'next-turn' ? agent.inbox.nextTurn : agent.inbox.nextStep + return splice?.target === target + ? messages.toSpliced(splice.start, splice.removedCount ?? 0, ...splice.inserted) + : messages + } + return [ + ...project('next-turn').map(message => ({ + id: message.id, + placement: 'queued' as const, + message: { id: message.id, content: message.content as unknown as JsonValue[] }, + })), + ...project('next-step').map(message => ({ + id: message.id, + placement: message.source.kind === 'user' ? 'steering' as const : 'context' as const, + message: { id: message.id, content: message.content as unknown as JsonValue[] }, + })), + ] +} + +function jobView(job: JobSnapshot): SessionJob { + return { + id: job.id, + kind: job.kind, + label: job.label, + status: job.status, + ...(job.detail === undefined ? {} : { detail: job.detail }), + startedAt: job.startedAt, + ...(job.finishedAt === undefined ? {} : { finishedAt: job.finishedAt }), + } +} diff --git a/packages/api/session-controller/src/history.ts b/packages/api/session-controller/src/history.ts new file mode 100644 index 0000000000..d78509e49b --- /dev/null +++ b/packages/api/session-controller/src/history.ts @@ -0,0 +1,350 @@ +/** Cold Session history pagination and live-event source. */ + +import type { Context } from '@deepseek-ai/cordis' +import { isAppendSurfaceEvent } from '@deepseek-ai/dsh-session' +import { isChunkRow, packChunkRuns, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { SessionQueryError, type SessionObservation } from '@deepseek-ai/dsh-session-query' +import type {} from '@deepseek-ai/dsh-subagent' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { + SessionAddress, + SessionChunkRun, + SessionEventEntry, + SessionFollowRequest, + SessionFollowFrame, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, + SessionProjectionValues, + SessionWireEvent, +} from './types.ts' + +const DEFAULT_MAX_MESSAGES = 50 +const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) + +/** Implements cold-safe history operations delegated by the Session Controller. */ +export class SessionHistoryController { + private readonly closeFollowers = new Set<() => void>() + + /** + * @param ctx - Host context carrying Session query and projection services. + * @param promote - starts ordinary Session activation after snapshot delivery. + */ + constructor( + private readonly ctx: Context, + private readonly promote: (observation: SessionObservation) => void, + ) { + ctx.effect(() => () => { + for (const close of this.closeFollowers) close() + this.closeFollowers.clear() + }, 'session-controller.history') + } + + /** + * Read one message-aligned history page without activating an Agent. + * @param request - durable address and backwards-page cursor. + * @param signal - caller cancellation for persistence reads. + * @returns a contiguous event page. + */ + async page(request: SessionPageRequest, signal: AbortSignal): Promise { + validatePageRequest(request) + using source = await this.sourceFor(request.address, signal, false) + signal.throwIfAborted() + const sourceLog = source.events + const sourceCursor = sourceLog.at(-1)?.seq ?? -1 + if (request.throughSeq > sourceCursor) { + reject( + 'bad-request', + `session page through seq ${String(request.throughSeq)} is past cursor ${String(sourceCursor)}`, + {}, + ) + } + /* v8 ignore next -- Session and persistence validation guarantee a dense zero-based event prefix. */ + if (request.throughSeq >= 0 && sourceLog[request.throughSeq]?.seq !== request.throughSeq) { + reject('internal', `session log does not contain through seq ${String(request.throughSeq)}`, {}) + } + const page = paginate( + sourceLog, + request.beforeSeq, + request.maxMessages ?? DEFAULT_MAX_MESSAGES, + request.throughSeq, + ) + const records = pageRecords(page.events) + return { + records, + hasMore: page.hasMore, + } + } + + /** + * Follow events appended after an initial cursor on one durable address. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - stream cancellation owned by the Remote carrier. + * @returns a complete opening snapshot followed by gap-free event frames. + */ + async *follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + validateFollowRequest(request) + const { address } = request + const target = addressId(address) + const buffered: SessionEvent[] = [] + let snapshotCursor: number | undefined + let wake: (() => void) | undefined + const notify = (): void => { + const resume = wake + wake = undefined + resume?.() + } + const follower = { closed: false } + const close = (): void => { + follower.closed = true + notify() + } + this.closeFollowers.add(close) + const disposeEvent = this.ctx.on('session/event', (session, event) => { + if (session.id !== target) return + buffered.push(event) + notify() + }, { global: true }) + const disposeCreated = this.ctx.on('session/created', (session) => { + if (session.id !== target) return + // Constructor seed events have no session/event notification. Normally + // only the end-seed suffix is new; if persistence advanced after the + // opening observation, replay everything beyond that snapshot cursor. + const suffix = session.events.slice(snapshotCursor === undefined + ? session.firstLiveSeq + : snapshotCursor + 1) + buffered.unshift(...suffix) + notify() + }, { global: true }) + const onAbort = (): void => { notify() } + signal.addEventListener('abort', onAbort, { once: true }) + try { + using source = await this.sourceFor(address, signal, true) + const events = source.events + signal.throwIfAborted() + const cursor = source.cursor + snapshotCursor = cursor + const page = paginate(events, undefined, request.maxMessages ?? DEFAULT_MAX_MESSAGES) + yield { + type: 'snapshot', + header: source.header, + cursor, + records: pageRecords(page.events), + hasMore: page.hasMore, + projections: source.projections === undefined + ? { asOfSeq: cursor, values: {} } + : projectionBlock(source.projections), + } + if (address.kind === 'session' && source.source === 'prepared') { + const promotion = source.retain() + try { + this.promote(promotion) + } catch (error: unknown) { + promotion[Symbol.dispose]() + throw error + } + } + let nextSeq = cursor + 1 + while (!follower.closed && !signal.aborted) { + const item = buffered.shift() + if (item === undefined) { + await new Promise((resolve) => { wake = resolve }) + continue + } + if (item.seq < nextSeq) continue + if (item.seq !== nextSeq) { + reject('internal', `session event stream skipped seq ${String(nextSeq)}`, {}) + } + nextSeq++ + yield entryFor(item) + } + } finally { + this.closeFollowers.delete(close) + signal.removeEventListener('abort', onAbort) + disposeCreated() + disposeEvent() + } + } + + private async sourceFor( + address: SessionAddress, + signal: AbortSignal, + withProjections: boolean, + ): Promise { + const sessionId = addressId(address) + try { + const observation = await this.ctx.sessionQuery.observeSession(sessionId, { + signal, + projectionMode: withProjections || address.kind === 'subagent' ? 'all' : 'none', + }) + if (observation.header.cwd === undefined) { + observation[Symbol.dispose]() + rejectNotFound(address) + } + try { + validateAddress(address, observation.header, observation.projections) + } catch (error: unknown) { + observation[Symbol.dispose]() + throw error + } + return observation + } catch (error: unknown) { + if (error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_SESSION_NOT_FOUND') rejectNotFound(address) + throw error + } + } + +} + +function projectionBlock( + snapshot: NonNullable, +): SessionProjectionBaseline { + return { + asOfSeq: snapshot.asOfSeq, + // Projection definitions validate whole JSON values before snapshot publication. + values: snapshot.values as SessionProjectionValues, + } +} + +function validatePageRequest(request: SessionPageRequest): void { + if (!Number.isSafeInteger(request.throughSeq) || request.throughSeq < -1) { + reject('bad-request', 'throughSeq must be an integer greater than or equal to -1', {}) + } + if (request.beforeSeq !== undefined + && (!Number.isSafeInteger(request.beforeSeq) || request.beforeSeq < 0)) { + reject('bad-request', 'beforeSeq must be a non-negative safe integer', {}) + } + if (request.maxMessages !== undefined + && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { + reject('bad-request', 'maxMessages must be a positive safe integer', {}) + } +} + +function validateFollowRequest(request: SessionFollowRequest): void { + if (request.maxMessages !== undefined + && (!Number.isSafeInteger(request.maxMessages) || request.maxMessages <= 0)) { + reject('bad-request', 'maxMessages must be a positive safe integer', {}) + } +} + +function addressId(address: SessionAddress): SessionId { + return address.kind === 'session' ? address.sessionId : address.childSessionId +} + +function validateAddress( + address: SessionAddress, + header: SessionHeader, + projections: SessionObservation['projections'], +): void { + if (address.kind === 'session') { + if (header.origin === 'subagent') { + reject('agent-busy', 'subagent Sessions require their durable parent address', { + reason: 'use subagent delivery for this child session', + }) + } + return + } + if (header.origin !== 'subagent' || header.parentSession !== address.parentSessionId) { + reject('subagent-unauthorized', 'subagent does not belong to the supplied parent', { + childSessionId: address.childSessionId, + }) + } + const identity = projections?.values.subagent + if (identity === null) { + reject('subagent-catalog-diagnostic', 'subagent descriptor is corrupt', { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: 'corrupt', + }) + } + if (identity === undefined || identity.seq < (header.seedLength ?? 0)) { + reject('subagent-catalog-diagnostic', 'subagent descriptor is unavailable', { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + reason: 'unsupported', + }) + } + if (identity.mode !== address.mode) { + reject('subagent-unauthorized', 'subagent mode does not match the supplied address', { + childSessionId: address.childSessionId, + }) + } +} + +function rejectNotFound(address: SessionAddress): never { + if (address.kind === 'session') { + reject('session-not-found', `session "${address.sessionId}" not found`, { sessionId: address.sessionId }) + } + reject('subagent-not-found', 'subagent is unavailable', { + parentSessionId: address.parentSessionId, + childSessionId: address.childSessionId, + }) +} + +function reject(code: string, message: string, details: object): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +function paginate( + events: readonly SessionEvent[], + beforeSeq: number | undefined, + maxMessages: number, + throughSeq = events.at(-1)?.seq ?? -1, +): { readonly events: SessionEvent[]; readonly hasMore: boolean } { + const end = Math.min(throughSeq + 1, beforeSeq ?? throughSeq + 1) + let count = 0 + let cut = 0 + for (let index = end - 1; index >= 0; index--) { + const event = events[index] as SessionEvent + if (!MESSAGE_TYPES.has(event.type) || !isAppendSurfaceEvent(event)) continue + count++ + const sources = (event as { readonly sourceEventSeqs?: readonly number[] }).sourceEventSeqs + let groupStart = event.seq + if (sources !== undefined) { + for (const source of sources) groupStart = Math.min(groupStart, source) + } + if (count >= maxMessages) { + cut = groupStart + break + } + } + return { events: events.slice(cut, end), hasMore: cut > 0 } +} + +function entryFor(event: SessionEvent): SessionEventEntry { + return { + type: 'event', + // Session.append validates and freezes event data as JSON before publication. + event: event as unknown as SessionWireEvent, + } +} + +function chunkEntryFor(row: ChunkRow): SessionChunkRun { + switch (row.type) { + case 'text-chunks': + return { + type: 'chunks', + event: { type: 'chunkrow/text-chunks', seq: row.seq0, time: row.time0, data: row.data }, + } + case 'reasoning-chunks': + return { + type: 'chunks', + event: { type: 'chunkrow/reasoning-chunks', seq: row.seq0, time: row.time0, data: row.data }, + } + case 'tool-call-chunks': + return { + type: 'chunks', + event: { type: 'chunkrow/tool-call-chunks', seq: row.seq0, time: row.time0, data: row.data }, + } + } +} + +/** Encode one bounded logical page without changing its pagination cut. */ +function pageRecords(events: readonly SessionEvent[]): SessionHistoryRecord[] { + return packChunkRuns(events).map(record => isChunkRow(record) + ? chunkEntryFor(record) + : entryFor(record)) +} diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts new file mode 100644 index 0000000000..a3ce201aaf --- /dev/null +++ b/packages/api/session-controller/src/index.ts @@ -0,0 +1,314 @@ +/** Session Remote owner: cold reads, explicit Agent commands, and live control state. */ + +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { errorChain } from '@deepseek-ai/dsh-llm' +import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { + ApiSessionAgentController, + inspectApiSession, + type ApiSessionAgentResult, +} from './agent.ts' +import { SessionCommandController } from './commands.ts' +import { SessionControlController } from './control.ts' +import { SessionHistoryController } from './history.ts' +import { ApiSessionList, DEFAULT_COLD_BLANK_PROBE_MAX_BYTES } from './list.ts' +import { installModelSelectionProjection } from './model-selection-projection.ts' +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionControlFrame, + SessionCreateRequest, + SessionCreateValue, + SessionFollowFrame, + SessionFollowRequest, + SessionForkRequest, + SessionForkValue, + SessionListRequest, + SessionListValue, + SessionPage, + SessionPageRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSearchRequest, + SessionSearchValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from './types.ts' + +export type * from './types.ts' +export { ApiSessionNotFound } from './agent.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Session business API and Remote namespace owner. */ + sessionController: SessionController + } +} + +/** Session Controller deployment policy. */ +export interface Config { + /** Maximum cold Session artifact size eligible for one full projection observation. */ + readonly coldBlankProbeMaxBytes?: number +} + +/** Host service backing the generated `ctx.remote.session` namespace. */ +export class SessionController extends TypertRemoteService { + static inject = [ + 'agentDefaultModel', + 'agents', + 'attachments', + 'llm', + 'sessions', + 'sessionProjections', + 'sessionQuery', + 'typert', + 'workspaceRegistry', + ] + + static Config: z = z.object({ + coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), + }) + + private readonly agents: ApiSessionAgentController + private readonly commands: SessionCommandController + private readonly controlState: SessionControlController + private readonly history: SessionHistoryController + private readonly listState: ApiSessionList + private readonly promotions = new Set>() + + /** + * @param ctx - Host context containing the Session capability assembly. + * @param config - cold-list observation policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'sessionController', { namespace: 'session' }) + installModelSelectionProjection(ctx) + this.agents = new ApiSessionAgentController(ctx) + this.commands = new SessionCommandController(ctx, this.agents, process.cwd()) + this.controlState = new SessionControlController(ctx) + // Registered before history so reverse-order teardown closes every + // follower before waiting for already-admitted promotions. + ctx.effect(() => async () => { + await Promise.allSettled([...this.promotions]) + }, 'session-controller.promotions') + this.history = new SessionHistoryController(ctx, (observation) => { this.promote(observation) }) + this.listState = new ApiSessionList( + ctx, + config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, + ) + + ctx.on('session/created', (session) => { + ctx.emit('api-session/added', this.listState.summaryFor(session)) + }) + ctx.on('session/disposed', (session) => { + ctx.emit('api-session/removed', session.id) + }) + ctx.on('agent/status', ({ agent, status }) => { + ctx.emit('api-session/status', agent.id, status === 'running') + }) + ctx.on('agent/error', ({ agent, error }) => { + ctx.emit('api-session/error', agent.id, errorChain(error)) + }) + ctx.on('session/event', (session, event) => { + if (event.type === 'request/header') { + const agent = ctx.agents.get(session.id) + if (agent?.session === session) this.agents.consumeSelection( + agent, + event.data.header.config.provider, + event.data.header.config.model, + event.data.header.config.reasoningEffort, + ) + } + if (event.type !== 'user/message' || event.data.source.kind !== 'user') return + ctx.emit('api-session/activity', session.id, event.time) + }) + } + + private promote(observation: SessionObservation): void { + const sessionId = observation.header.id + const task = (async () => { + using ownedObservation = observation + const result = await this.agents.resolveObservedAgent(ownedObservation) + if ('error' in result) this.ctx.emit('api-session/error', sessionId, result.error.message) + })().catch((error: unknown) => { + this.ctx.logger.error(`session-controller: background activation for "${sessionId}" failed: ${errorChain(error)}`) + }) + this.promotions.add(task) + void task.finally(() => { this.promotions.delete(task) }) + } + + /** + * Resolve or resume one ordinary Session for another Host API domain. + * @param sessionId - Session identity whose Agent owns the operation. + * @returns the live Agent or the stable Session-domain failure. + */ + resolveAgent(sessionId: SessionId): Promise { + return this.agents.resolveAgent(sessionId) + } + + /** + * Inspect one attached or persisted Session without activating its Agent. + * @param sessionId - durable Session identity. + * @param signal - optional caller cancellation for persistence reads. + * @returns the current attached state or persisted header and event prefix. + */ + inspect( + sessionId: SessionId, + signal?: AbortSignal, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + const attached = this.ctx.sessions.get(sessionId) + if (attached !== undefined) { + return Promise.resolve({ meta: attached.header, events: [...attached.events] }) + } + return inspectApiSession(this.ctx, sessionId, signal) + } + + /** + * Read all visible Session rows without resuming an Agent. + * @param _request - reserved empty list request. + * @param signal - cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ + @Remote('list') + async list(_request: SessionListRequest, signal: AbortSignal): Promise { + return { items: await this.listState.list(signal) } + } + + /** + * Search visible Session content without resuming an Agent. + * @param request - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + @Remote('search') + search(request: SessionSearchRequest, signal: AbortSignal): Promise { + return this.listState.search(request.query, signal) + } + + /** + * Create or idempotently adopt one ordinary Session. + * @param request - requested identity, location, and Agent preset. + * @returns the Session identity and resolved preset when configured. + */ + @Remote('create') + create(request: SessionCreateRequest): Promise { + return this.commands.create(request) + } + + /** + * Select one Session-local model after explicitly resuming the Session. + * @param request - Session identity and requested model selection. + * @returns the normalized selection installed for the Session. + */ + @Remote('selectModel') + selectModel(request: SessionSelectModelRequest): Promise { + return this.commands.selectModel(request) + } + + /** + * Rename one Session after explicitly resuming it. + * @param request - Session identity and proposed title. + * @returns the accepted title and durable event sequence. + */ + @Remote('rename') + rename(request: SessionRenameRequest): Promise { + return this.commands.rename(request) + } + + /** + * Fork one cold-readable completed-turn prefix into a new Session. + * @param request - source Session and optional event anchor. + * @returns the new Session identity. + */ + @Remote('fork') + fork(request: SessionForkRequest): Promise { + return this.commands.fork(request) + } + + /** + * Admit one prompt after explicitly resuming its Session. + * @param request - Session identity, prompt content, source metadata, and delivery mode. + * @param signal - caller cancellation before prompt admission begins. + * @returns acknowledgement that the Agent accepted the prompt. + */ + @Remote('prompt') + prompt(request: SessionPromptRequest, signal: AbortSignal): Promise { + signal.throwIfAborted() + return this.commands.prompt(request) + } + + /** + * Read one image proven reachable from the addressed Session log. + * @param request - Session and attachment identities used for authorization. + * @returns the durable attachment reference and base64-encoded bytes. + */ + @Remote('attachment') + attachment(request: SessionAttachmentRequest): Promise { + return this.commands.attachment(request) + } + + /** + * Mutate one still-pending queue occurrence on a live Agent. + * @param request - Session, queue item, and requested mutation. + * @returns acknowledgement that the queue mutation was applied. + */ + @Remote('updateQueue') + updateQueue(request: SessionUpdateQueueRequest): SessionUpdateQueueValue { + return this.commands.updateQueue(request) + } + + /** + * Cancel one active Agent turn without dropping its pending inbox. + * @param request - Session whose active Agent turn is cancelled. + * @returns acknowledgement that cancellation was requested. + */ + @Remote('cancel') + cancel(request: SessionCancelRequest): SessionCancelValue { + return this.commands.cancel(request) + } + + /** + * Read one cold-safe, message-aligned Session history page. + * @param request - durable address, backward cursor, and page budget. + * @param signal - cancellation for persistence reads. + * @returns one chronological page. + */ + @Remote('page') + page(request: SessionPageRequest, signal: AbortSignal): Promise { + return this.history.page(request, signal) + } + + /** + * Follow one Session log from its opening or resume cursor. + * @param request - durable address and last committed sequence already held by the caller. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns a complete opening snapshot followed by gap-free event frames. + */ + @Remote({ mode: 'stream' }) + follow(request: SessionFollowRequest, signal: AbortSignal): AsyncIterable { + return this.history.follow(request, signal) + } + + /** + * Stream a complete live-control baseline followed by replacement frames. + * @param signal - cancellation owned by the Remote stream carrier. + * @returns one complete baseline followed by live replacement frames. + */ + @Remote({ mode: 'stream' }) + control(signal: AbortSignal): AsyncIterable { + return this.controlState.control(signal) + } + +} + +export { buildModelCatalog } from './catalog.ts' +export default SessionController diff --git a/packages/api/session-controller/src/invariant.ts b/packages/api/session-controller/src/invariant.ts new file mode 100644 index 0000000000..d225d978ff --- /dev/null +++ b/packages/api/session-controller/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-session-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-session-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-session-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: every page and frame is checked against the addressed durable Session. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/session-controller/src/list.ts b/packages/api/session-controller/src/list.ts new file mode 100644 index 0000000000..c9a53a6bd0 --- /dev/null +++ b/packages/api/session-controller/src/list.ts @@ -0,0 +1,389 @@ +/** Cold-safe Session list and search projection. */ + +import { stat } from 'node:fs/promises' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-session-projection-cache' +import { SessionQueryError, type SessionSearchCursor } from '@deepseek-ai/dsh-session-query' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { z } from 'zod' +import { + SESSION_SEARCH_RESULT_LIMIT, + SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, +} from './types.ts' +import type { + SessionListMetadata, SessionProjectionHints, SessionProjectionValues, SessionSearchItem, + SessionSearchValue, SessionSummary, +} from './types.ts' + +/** Default maximum artifact size eligible for one cold projection observation. */ +export const DEFAULT_COLD_BLANK_PROBE_MAX_BYTES = 1024 + +const COLD_SUMMARY_BATCH_SIZE = 16 +const SEARCH_PROVIDER_CALL_LIMIT = 100 +const SESSION_SEARCH_QUERY_MAX_CHARS = 500 +const MESSAGE_TYPES = new Set(['user/message', 'assistant/message']) + +const sessionListMetadataSchema: z.ZodType = z.object({ + blank: z.boolean(), + lastPromptAt: z.number().nullable(), +}) + +const imageLimitsSchema = z.object({ + maxImageBytes: z.number().int().positive(), + maxImagesPerMessage: z.number().int().positive(), + maxMessageImageBytes: z.number().int().positive(), + maxImagePixels: z.number().int().positive(), + maxImageDimension: z.number().int().positive(), + mediaTypes: z.array(z.string()), +}) as unknown as z.ZodType + +/** + * Advance the Session-list metadata projection by one committed event. + * @param state - metadata before the event. + * @param event - next committed Session event. + * @returns the original or advanced metadata value. + */ +export function applySessionListMetadata( + state: SessionListMetadata, + event: SessionEvent, +): SessionListMetadata { + const blank = state.blank && event.type !== 'turn/start' + const lastPromptAt = event.type === 'user/message' && event.data.source.kind === 'user' + ? event.time + : state.lastPromptAt + return blank === state.blank && lastPromptAt === state.lastPromptAt + ? state + : { blank, lastPromptAt } +} + +/** + * Return the longest prefix containing at most `maximum` Unicode code points. + * @param value - source text. + * @param maximum - maximum number of Unicode code points. + * @returns the source text or its longest allowed prefix. + */ +export function truncateUnicodeCodePoints(value: string, maximum: number): string { + let count = 0 + let end = 0 + for (const codePoint of value) { + if (count === maximum) return value.slice(0, end) + count++ + end += codePoint.length + } + return value +} + +/** Owns list projection registration, bounded cold summaries, and authorized search. */ +export class ApiSessionList { + /** + * @param ctx - Host context carrying Session, query, persistence, and projection services. + * @param coldBlankProbeMaxBytes - maximum physical artifact size eligible for a full observation. + */ + constructor( + private readonly ctx: Context, + private readonly coldBlankProbeMaxBytes: number, + ) { + ctx.inject(['sessionProjections'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'sessionListMetadata', SessionListMetadata>({ + key: 'sessionListMetadata', + stateSchema: sessionListMetadataSchema, + init: () => ({ blank: true, lastPromptAt: null }), + apply: applySessionListMetadata, + wire: { viewSchema: sessionListMetadataSchema, view: state => state }, + stateVersion: 1, + }) + }) + ctx.inject(['sessionProjections', 'attachments'], (projectionCtx) => { + projectionCtx.sessionProjections.register<'imageLimits', null>({ + key: 'imageLimits', + stateSchema: z.null(), + init: () => null, + apply: state => state, + wire: { + viewSchema: imageLimitsSchema, + view: () => projectionCtx.attachments.imageLimits, + }, + stateVersion: 1, + }) + }) + } + + /** + * Build one current attached-Session summary. + * @param session - attached Session to summarize. + * @returns current list metadata and available projections. + */ + summaryFor(session: Session): SessionSummary { + const projections = this.projectionsFor(session.header, session) + const metadata = projections?.values.sessionListMetadata + return { + sessionId: session.id, + updatedAt: updatedAt(session.header, metadata), + running: this.ctx.agents.get(session.id)?.status === 'running', + blank: metadata?.blank ?? session.seq === 0, + ...listFields(session.header), + ...(projections === undefined ? {} : { projections }), + } + } + + /** + * Read every visible attached and persisted Session without activating an Agent. + * @param signal - optional cancellation for persistence reads. + * @returns visible Session summaries ordered by activity. + */ + async list(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + const records = await this.ctx.sessionQuery.listSessions(signal) + signal?.throwIfAborted() + const items: SessionSummary[] = [] + const cold: SessionHeader[] = [] + for (const record of records) { + const live = this.ctx.sessions.get(record.header.id) + if (live !== undefined) { + items.push(this.summaryFor(live)) + continue + } + if (record.header.cwd === undefined) continue + cold.push(record.header) + } + for (let offset = 0; offset < cold.length; offset += COLD_SUMMARY_BATCH_SIZE) { + const settled = await Promise.allSettled(cold.slice(offset, offset + COLD_SUMMARY_BATCH_SIZE) + .map(header => this.summarizeCold(header, signal))) + for (const result of settled) { + if (result.status === 'rejected') throw result.reason + items.push(result.value) + } + } + items.sort((left, right) => right.updatedAt - left.updatedAt) + return items + } + + private async summarizeCold( + header: SessionHeader, + signal: AbortSignal | undefined, + ): Promise { + const cached = this.projectionsFor(header, undefined) + const projections = cached?.values.sessionListMetadata?.blank === false + ? cached + : await this.probeSmallCold(header, signal) ?? cached + const raced = this.ctx.sessions.get(header.id) + if (raced !== undefined) return this.summaryFor(raced) + const metadata = projections?.values.sessionListMetadata + return { + sessionId: header.id, + updatedAt: updatedAt(header, metadata), + running: false, + // A large or inaccessible cache miss remains unknown and visible. + blank: metadata?.blank ?? false, + ...listFields(header), + ...(projections === undefined ? {} : { projections }), + } + } + + private async probeSmallCold( + header: SessionHeader, + signal: AbortSignal | undefined, + ): Promise { + if (this.coldBlankProbeMaxBytes === 0) return undefined + const persistence = this.ctx.get('sessionPersistence') + const location = persistence?.locate(header) + if (location === undefined) return undefined + signal?.throwIfAborted() + try { + if ((await stat(location.path)).size > this.coldBlankProbeMaxBytes) return undefined + } catch { + signal?.throwIfAborted() + return undefined + } + try { + using observation = await this.ctx.sessionQuery.observeSession(header.id, { + ...(signal === undefined ? {} : { signal }), + projectionMode: 'all', + }) + const block = observation.projections + return block === undefined + ? undefined + : { asOfSeq: block.asOfSeq, values: block.values as SessionProjectionValues } + } catch (error: unknown) { + signal?.throwIfAborted() + this.ctx.logger.warn( + `api-session.list: small cold observation for "${header.id}" failed; serving it as visible: ${String(error)}`, + ) + return undefined + } + } + + /** + * Search current visible message content without activating any matching Session. + * @param query - literal message-content query. + * @param signal - cancellation for list and search reads. + * @returns authorized bounded Session search results. + */ + async search(query: string, signal: AbortSignal): Promise { + const normalizedQuery = normalizeSearchQuery(query) + signal.throwIfAborted() + const provider = this.ctx.get('sessionQuery') + if (provider === undefined) { + reject( + 'internal', + 'session search is unavailable: this deployment does not mount @deepseek-ai/dsh-session-query', + {}, + ) + } + try { + const visible = await provider.listSessions(signal) + signal.throwIfAborted() + const visibleIds = new Set(visible + .filter(record => record.header.cwd !== undefined) + .map(record => record.header.id)) + if (visibleIds.size === 0) return { items: [], hasMore: false } + const authorized: SessionSearchItem[] = [] + const acceptedIds = new Set() + const seenCursors = new Set() + let cursor: SessionSearchCursor | undefined + let providerCalls = 0 + let pageLimit = SESSION_SEARCH_RESULT_LIMIT + while (authorized.length <= SESSION_SEARCH_RESULT_LIMIT) { + signal.throwIfAborted() + if (providerCalls >= SEARCH_PROVIDER_CALL_LIMIT) { + throw new Error(`session search provider exceeded the ${SEARCH_PROVIDER_CALL_LIMIT}-call work budget`) + } + providerCalls++ + const requestedCursor = cursor + const requestedLimit = pageLimit + let page + try { + page = await provider.searchSessions({ + query: normalizedQuery, + eventFilters: [ + { kind: 'type', values: ['user/message', 'assistant/message'] }, + { kind: 'surface', values: ['current'] }, + ], + limit: requestedLimit, + ...(requestedCursor === undefined ? {} : { cursor: requestedCursor }), + }, { signal }) + signal.throwIfAborted() + } catch (error: unknown) { + signal.throwIfAborted() + if (requestedCursor === undefined + && error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_INVALID_LIMIT' + && requestedLimit > 1) { + pageLimit = Math.max(1, Math.floor(requestedLimit / 2)) + continue + } + if (requestedCursor !== undefined + && error instanceof SessionQueryError + && error.code === 'SESSION_QUERY_STALE_CURSOR') { + authorized.length = 0 + acceptedIds.clear() + seenCursors.clear() + cursor = undefined + continue + } + throw error + } + if (page.items.length > requestedLimit) { + throw new Error(`session search provider returned ${String(page.items.length)} items; maximum is ${String(requestedLimit)}`) + } + for (const hit of page.items) { + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT) continue + if (!visibleIds.has(hit.header.id) + || hit.bestMatch.sessionId !== hit.header.id + || hit.bestMatch.surface !== 'current' + || !MESSAGE_TYPES.has(hit.bestMatch.type) + || acceptedIds.has(hit.header.id)) continue + acceptedIds.add(hit.header.id) + authorized.push({ + sessionId: hit.header.id, + snippet: truncateUnicodeCodePoints(hit.bestMatch.snippet, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS), + }) + } + if (page.nextCursor !== undefined) { + if (seenCursors.has(page.nextCursor)) { + throw new Error('session search provider repeated a continuation cursor') + } + seenCursors.add(page.nextCursor) + } + if (authorized.length > SESSION_SEARCH_RESULT_LIMIT || page.nextCursor === undefined) break + cursor = page.nextCursor + } + return { + items: authorized.slice(0, SESSION_SEARCH_RESULT_LIMIT), + hasMore: authorized.length > SESSION_SEARCH_RESULT_LIMIT, + } + } catch (error: unknown) { + signal.throwIfAborted() + if (error instanceof SessionQueryError && error.code === 'SESSION_QUERY_ABORTED') { + reject('cancelled', 'session search was aborted', {}) + } + reject('internal', `session search failed: ${String(error)}`, {}) + } + } + + private projectionsFor( + header: SessionHeader, + session: Session | undefined, + ): SessionProjectionHints | undefined { + try { + const block = session === undefined + ? this.ctx.get('sessionProjectionCache')?.cachedSnapshot(header) + : this.ctx.get('sessionProjections')?.cachedSnapshot(session) + return block !== undefined && Object.keys(block.values).length > 0 + ? { + asOfSeq: block.asOfSeq, + // Listing hints contain every currently cached wire value but remain + // partial: missing cells and cache rows are never materialized here. + values: block.values as SessionProjectionValues, + } + : undefined + } catch (error) { + this.ctx.logger.warn( + `api-session.list: projection column for "${header.id}" failed; serving the row without it: ${String(error)}`, + ) + return undefined + } + } +} + +function normalizeSearchQuery(query: string): string { + const normalized = query.trim() + if (normalized.length === 0) { + reject('bad-request', 'session search query must not be empty', {}) + } + if (normalized.length > SESSION_SEARCH_QUERY_MAX_CHARS) { + reject( + 'bad-request', + `session search query must contain at most ${SESSION_SEARCH_QUERY_MAX_CHARS} UTF-16 code units`, + {}, + ) + } + if (normalized.includes('\0')) { + reject('bad-request', 'session search query must not contain NUL', {}) + } + return normalized +} + +function reject(code: string, message: string, details: object): never { + throw new TypertRemoteFailure({ code, message, details }) +} + +function updatedAt(header: SessionHeader, metadata: SessionListMetadata | undefined): number { + return Math.max(header.createdAt, metadata?.lastPromptAt ?? 0) +} + +function listFields(header: SessionHeader): { + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string +} { + return { + ...(header.parentSession === undefined ? {} : { parentSessionId: header.parentSession }), + ...(header.origin === undefined ? {} : { origin: header.origin }), + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + } +} diff --git a/packages/api/session-controller/src/model-selection-projection.ts b/packages/api/session-controller/src/model-selection-projection.ts new file mode 100644 index 0000000000..a914dedb97 --- /dev/null +++ b/packages/api/session-controller/src/model-selection-projection.ts @@ -0,0 +1,83 @@ +/** Durable model-selection intent and request-use projection. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { z } from 'zod' +import type { + ModelSelection, + ModelSelectionProjection, + ModelSelectionProjectionState, +} from './types.ts' + +const modelSelectionSchema = z.object({ + provider: z.string().min(1), + model: z.string().min(1), + reasoningEffort: z.string().min(1).optional(), +}) as unknown as z.ZodType + +const modelSelectionProjectionStateSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + pending: modelSelectionSchema.nullable(), +}) as unknown as z.ZodType + +const modelSelectionProjectionSchema = z.object({ + lastUsed: modelSelectionSchema.nullable(), + next: modelSelectionSchema.nullable(), +}) as unknown as z.ZodType + +/** + * Advance durable model-selection state by one Session event. + * @param state - selection state before the event. + * @param event - next committed Session event. + * @returns the original or advanced selection state. + */ +function applyModelSelectionProjection( + state: ModelSelectionProjectionState, + event: SessionEvent, +): ModelSelectionProjectionState { + if (event.type === 'model/selection') { + return sameSelection(state.pending, event.data) + ? state + : { lastUsed: state.lastUsed, pending: event.data } + } + if (event.type !== 'request/header') return state + const lastUsed: ModelSelection = { + provider: event.data.header.config.provider, + model: event.data.header.config.model, + ...(event.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: String(event.data.header.config.reasoningEffort) }), + } + const pending = sameSelection(state.pending, lastUsed) ? null : state.pending + return sameSelection(state.lastUsed, lastUsed) && pending === state.pending + ? state + : { lastUsed, pending } +} + +const modelSelectionProjection = { + key: 'modelSelection', + stateSchema: modelSelectionProjectionStateSchema, + init: () => ({ lastUsed: null, pending: null }), + apply: applyModelSelectionProjection, + wire: { + viewSchema: modelSelectionProjectionSchema, + view: state => ({ lastUsed: state.lastUsed, next: state.pending ?? state.lastUsed }), + }, + stateVersion: 2, +} satisfies ProjectionDefinition<'modelSelection', ModelSelectionProjectionState> + +function sameSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { + return left === right || (left !== null && right !== null + && left.provider === right.provider + && left.model === right.model + && left.reasoningEffort === right.reasoningEffort) +} + +/** + * Register the durable model-selection projection when the registry is present. + * @param ctx - Session Controller context. + */ +export function installModelSelectionProjection(ctx: Context): void { + ctx.sessionProjections.register(modelSelectionProjection) +} diff --git a/packages/api/session-controller/src/remote-events.ts b/packages/api/session-controller/src/remote-events.ts new file mode 100644 index 0000000000..94d194d72a --- /dev/null +++ b/packages/api/session-controller/src/remote-events.ts @@ -0,0 +1,13 @@ +/** Session Controller events forwarded unchanged through the Remote Event carrier. */ +export const SESSION_CONTROLLER_REMOTE_EVENTS = [ + 'api-session/activity', + 'api-session/added', + 'api-session/error', + 'api-session/removed', + 'api-session/status', +] as const + +declare module '@deepseek-ai/dsh-typert-protocol' { + interface TypertRemoteEventSelection extends + Record {} +} diff --git a/packages/api/session-controller/src/types.ts b/packages/api/session-controller/src/types.ts new file mode 100644 index 0000000000..c045e00707 --- /dev/null +++ b/packages/api/session-controller/src/types.ts @@ -0,0 +1,514 @@ +/** Browser-safe request, result, and lifecycle vocabulary for the Session Remote service. */ + +import type { + AttachmentIdType, ImageAttachmentLimits, ImageAttachmentRef, ImageMediaType, +} from '@deepseek-ai/dsh-attachment' +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' +import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import type { JsonValue, SessionHeader, SessionId, SurfaceOp } from '@deepseek-ai/dsh-session/types' +import type { SessionProjectionMap } from '@deepseek-ai/dsh-session-projection/types' +import type { JobId } from '@deepseek-ai/dsh-jobs/brand' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + /** Host state persisted for cold Session list summaries. */ + sessionListMetadata: SessionListMetadata + /** Host state for the boot-constant image-limit view. */ + imageLimits: null + /** Durable model selection already used by a request and still pending for a later request. */ + modelSelection: ModelSelectionProjectionState + } + interface SessionProjectionMap { + /** Persisted facts used to summarize a Session without activating it. */ + sessionListMetadata: SessionListMetadata + /** Image-intake limits enforced by the Session prompt endpoint. */ + imageLimits: ImageAttachmentLimits + /** Durable model selection already used and selected for the next request. */ + modelSelection: ModelSelectionProjection + } +} + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * Complete validated model selection requested for subsequent prompt + * assembly. Log-only: it never enters derived model history. + */ + 'model/selection': ModelSelection + } +} + +/** Persisted hints used to summarize a cold Session. */ +export interface SessionListMetadata { + /** Whether the folded prefix contains no turn. */ + readonly blank: boolean + /** Latest human-authored prompt time in the folded prefix. */ + readonly lastPromptAt: number | null +} + +/** Every available cached wire value used as partial, possibly stale Session-list hints. */ +export interface SessionProjectionHints { + readonly asOfSeq: number + /** Provider-validated values present in the cache; omitted keys remain unknown. */ + readonly values: SessionProjectionValues +} + +/** Complete projection values at an exact Session event cursor. */ +export interface SessionProjectionBaseline { + readonly asOfSeq: number + /** Provider-validated values; omitted keys are absent capabilities at this cut. */ + readonly values: SessionProjectionValues +} + +/** Typed known projections plus JSON-safe values contributed outside this compilation face. */ +export type SessionProjectionValues = Partial + & Readonly> + +/** Browser-submitted prompt content; the Host promotes image bytes to durable references. */ +export type PromptContentPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageMediaType + readonly data: string + readonly name?: string + } + +/** Complete model selection for one Session. */ +export interface ModelSelection { + readonly provider: string + readonly model: string + readonly reasoningEffort?: string +} + +/** Host fold state for durable model selection. */ +export interface ModelSelectionProjectionState { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Later user selection not yet consumed by a matching model request. */ + readonly pending: ModelSelection | null +} + +/** Client view of the durable model-selection fold. */ +export interface ModelSelectionProjection { + /** Selection consumed by the latest recorded model request. */ + readonly lastUsed: ModelSelection | null + /** Selection the next request should use, falling back to {@link lastUsed}. */ + readonly next: ModelSelection | null +} + +/** One adapter-owned reasoning effort for an exact model route. */ +export interface ModelReasoningEffort { + readonly id: string + readonly name: string + readonly description?: string +} + +/** Selectable reasoning metadata for one exact model route. */ +export interface ModelReasoning { + readonly efforts: readonly ModelReasoningEffort[] + readonly defaultEffort?: string +} + +/** One model displayed inside its provider group. */ +export interface ModelCatalogModel { + readonly id: string + readonly name: string + readonly description?: string + readonly reasoning?: ModelReasoning +} + +/** One provider and its successfully loaded model catalog. */ +export interface ModelProviderGroup { + readonly id: string + readonly name: string + readonly models: readonly ModelCatalogModel[] +} + +/** One provider whose model catalog lookup failed. */ +export interface ModelCatalogFailure { + readonly id: string + readonly name: string + readonly message: string +} + +/** Host-generation model catalog and the default used by unconfigured Sessions. */ +export interface ModelCatalog { + readonly default: ModelSelection + /** Provider routes currently able to serve a request, including empty catalogs. */ + readonly routableProviders: readonly string[] + readonly groups: readonly ModelProviderGroup[] + readonly failures: readonly ModelCatalogFailure[] +} + +/** One client-requested mutation of a still-pending queue item. */ +export type QueueAction = + | { readonly kind: 'edit'; readonly content: readonly ContentBlock[] } + | { readonly kind: 'remove' } + | { readonly kind: 'steer' } + +/** One Session list entry. */ +export interface SessionSummary { + readonly sessionId: SessionId + readonly updatedAt: number + readonly running: boolean + readonly blank: boolean + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string + readonly projections?: SessionProjectionHints +} + +/** One session-content search result. */ +export interface SessionSearchItem { + readonly sessionId: SessionId + readonly snippet: string +} + +/** Maximum number of Sessions returned by one search. */ +export const SESSION_SEARCH_RESULT_LIMIT = 20 + +/** Maximum search snippet length in Unicode code points. */ +export const SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS = 240 + +/** Error details returned by Session Remote methods. */ +export interface SessionErrorDetailsMap { + 'bad-request': Record + cancelled: Record + 'session-not-found': { readonly sessionId: SessionId } + 'model-unavailable': { readonly provider: string; readonly model: string } + 'session-conflict': { + readonly sessionId: SessionId + readonly requestedCwd: string + readonly existingCwd?: string + } + 'invalid-time-zone': { readonly value: string } + 'workspace-attach-failed': { readonly sessionId: SessionId; readonly workspaceId: string } + 'workspace-not-found': { readonly workspaceId: string } + 'agent-preset-conflict': { + readonly sessionId: SessionId + readonly requestedPreset: string + readonly existingPreset?: string + } + 'agent-preset-not-found': { readonly agentPreset: string; readonly available: readonly string[] } + 'agent-preset-invalid': { readonly agentPreset: string; readonly reason: string } + 'agent-busy': { readonly reason: string } + 'attachment-error': { readonly reason: string } + 'queue-item-not-found': { readonly itemId: MessageId } + 'steer-unavailable': { readonly itemId: MessageId } + 'title-invalid': { readonly sessionId: SessionId } + 'fork-unavailable': { readonly sessionId: SessionId } + 'subagent-not-found': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + } + 'subagent-catalog-diagnostic': { + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly reason: 'corrupt' | 'unsupported' | 'unavailable' + } + 'subagent-unauthorized': { readonly childSessionId: SessionId } + internal: Record +} + +/** Session business failure returned without throwing a carrier error. */ +export type SessionError = { + [Code in keyof SessionErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: SessionErrorDetailsMap[Code] + } +}[keyof SessionErrorDetailsMap] + +/** Session list request. */ +export interface SessionListRequest { + readonly cursor?: string +} + +/** Session list response value. */ +export interface SessionListValue { + readonly items: readonly SessionSummary[] +} + +/** Session search request. */ +export interface SessionSearchRequest { + readonly query: string +} + +/** Session search response value. */ +export interface SessionSearchValue { + readonly items: readonly SessionSearchItem[] + readonly hasMore: boolean +} + +/** Session creation or explicit-id adoption request. */ +export interface SessionCreateRequest { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string +} + +/** Session creation response value. */ +export interface SessionCreateValue { + readonly sessionId: SessionId + readonly agentPreset?: string +} + +/** Session model-selection request. */ +export interface SessionSelectModelRequest extends ModelSelection { + readonly sessionId: SessionId +} + +/** Accepted model selection after Host resolution. */ +export interface SessionSelectModelValue { + readonly selected: ModelSelection +} + +/** Session rename request. */ +export interface SessionRenameRequest { + readonly sessionId: SessionId + readonly title: string +} + +/** Normalized title and the durable event position that committed it. */ +export interface SessionRenameValue { + readonly title: string + readonly seq: number +} + +/** Session fork request. */ +export interface SessionForkRequest { + readonly sessionId: SessionId + readonly atSeq?: number +} + +/** Identity of a newly forked Session. */ +export interface SessionForkValue { + readonly sessionId: SessionId +} + +/** Session prompt request. */ +export interface SessionPromptRequest { + /** Client-minted identity persisted on the exact accepted user message. */ + readonly requestId: SessionRequestId + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly PromptContentPart[] + readonly clientTimeZone?: string +} + +/** Receipt after one prompt enters the target Agent inbox. */ +export interface SessionPromptValue { + readonly accepted: true +} + +/** Durable image read request. */ +export interface SessionAttachmentRequest { + readonly sessionId: SessionId + readonly attachmentId: AttachmentIdType +} + +/** Durable image read response value. */ +export interface SessionAttachmentValue { + readonly attachment: ImageAttachmentRef + readonly data: string +} + +/** Pending queue mutation request. */ +export interface SessionUpdateQueueRequest { + readonly sessionId: SessionId + readonly itemId: MessageId + readonly action: QueueAction +} + +/** Receipt after one pending queue mutation commits. */ +export interface SessionUpdateQueueValue { + readonly accepted: true +} + +/** Active-turn cancellation request. */ +export interface SessionCancelRequest { + readonly sessionId: SessionId +} + +/** Receipt after cancellation is admitted to the live Agent. */ +export interface SessionCancelValue { + readonly accepted: true +} + +/** Client-minted prompt identity used to reconcile optimistic and durable messages. */ +export type SessionRequestId = Branded<'session-request-id'> + +declare module '@deepseek-ai/dsh-llm' { + interface MessageSourceMap { + /** Browser prompt correlation and optional Host-validated time zone. */ + 'user-rpc': { kind: 'user'; rpcId: SessionRequestId; clientTimeZone?: string } + } +} + +/** Durable identity selecting an ordinary Session or one direct subagent child. */ +export type SessionAddress = + | { readonly kind: 'session'; readonly sessionId: SessionId } + | { + readonly kind: 'subagent' + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly mode: 'one-shot' | 'continuable' + } + +/** One raw Session event in the Remote journal. */ +export interface SessionEventEntry { + readonly type: 'event' + readonly event: SessionWireEvent +} + +/** Event-shaped wire representation of one packed chunk row. */ +export type ChunkRowEvent = { + [Kind in ChunkRow['type']]: { + readonly type: `chunkrow/${Kind}` + readonly seq: number + readonly time: number + readonly data: Extract['data'] + } +}[ChunkRow['type']] + +/** One lossless run of consecutive Assistant delta events in a history page. */ +export interface SessionChunkRun { + readonly type: 'chunks' + readonly event: ChunkRowEvent +} + +/** One history-page record: a raw event or a packed Assistant delta run. */ +export type SessionHistoryRecord = SessionEventEntry | SessionChunkRun + +/** Session event wire form; durable readers own recognition of merge-extensible event names. */ +export interface SessionWireEvent { + readonly type: string + readonly seq: number + readonly time: number + readonly data: JsonValue + readonly sourceEventSeqs?: number[] + readonly surfaceOp?: SurfaceOp +} + +/** One message-aligned backwards-history request. */ +export interface SessionPageRequest { + readonly address: SessionAddress + /** Inclusive log cut obtained from the corresponding follow opening frame. */ + readonly throughSeq: number + readonly beforeSeq?: number + readonly maxMessages?: number +} + +/** One live event request for a durable Session address. */ +export interface SessionFollowRequest { + readonly address: SessionAddress + readonly maxMessages?: number +} + +/** One contiguous backwards page of a Session log. */ +export interface SessionPage { + readonly records: readonly SessionHistoryRecord[] + readonly hasMore: boolean +} + +/** Complete opening window followed by ordered events appended after its cursor. */ +export type SessionFollowFrame = + | { + readonly type: 'snapshot' + readonly header: SessionHeader + readonly cursor: number + readonly records: readonly SessionHistoryRecord[] + readonly hasMore: boolean + readonly projections: SessionProjectionBaseline + } + | SessionEventEntry + +/** One pending inbox occurrence in the authoritative queue snapshot. */ +export interface SessionQueuedItem { + readonly id: MessageId + readonly placement: 'queued' | 'steering' | 'context' + /** JSON-safe message fields consumed by pending-queue presentation. */ + readonly message: { + readonly id: MessageId + readonly content: readonly JsonValue[] + } +} + +/** Browser-safe background-job row. */ +export interface SessionJob { + readonly id: JobId + readonly kind: string + readonly label: string + readonly status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + readonly detail?: string + readonly startedAt: number + readonly finishedAt?: number +} + +/** Complete live control baseline emitted once per control stream generation. */ +export interface SessionControlBaseline { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly projections: Readonly> +} + +/** One finished projection value and its durable watermark. */ +export interface SessionProjectionUpdate { + readonly sessionId: SessionId + readonly key: string + readonly value: JsonValue + readonly seq: number +} + +/** Host-wide live state stream. Each generation starts with exactly one baseline. */ +export type SessionControlFrame = + | { readonly type: 'baseline'; readonly value: SessionControlBaseline } + | { readonly type: 'queue'; readonly sessionId: SessionId; readonly items: readonly SessionQueuedItem[] } + | { readonly type: 'jobs'; readonly sessionId: SessionId; readonly jobs: readonly SessionJob[] } + | ({ readonly type: 'projection' } & SessionProjectionUpdate) + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A Session became visible to Session list consumers. + * @mode emit + * @param summary - initial list row for the Session. + */ + 'api-session/added'(summary: SessionSummary): void + /** + * A Session left the live Host registry. + * @mode emit + * @param sessionId - removed Session identity. + */ + 'api-session/removed'(sessionId: SessionId): void + /** + * One Agent changed running state. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param running - whether the Agent is running. + */ + 'api-session/status'(sessionId: SessionId, running: boolean): void + /** + * One user-authored durable message advanced Session list activity. + * @mode emit + * @param sessionId - addressed Session identity. + * @param updatedAt - durable message time used for list ordering. + */ + 'api-session/activity'(sessionId: SessionId, updatedAt: number): void + /** + * One Agent failed outside a durable turn position. + * @mode emit + * @param sessionId - Agent and Session identity. + * @param message - user-safe failure chain. + */ + 'api-session/error'(sessionId: SessionId, message: string): void + } +} + +/** JSON-compatible projection value accepted by list consumers. */ +export type SessionProjectionValue = JsonValue diff --git a/packages/api/session-controller/tests/agent.host.spec.ts b/packages/api/session-controller/tests/agent.host.spec.ts new file mode 100644 index 0000000000..3f4e721228 --- /dev/null +++ b/packages/api/session-controller/tests/agent.host.spec.ts @@ -0,0 +1,435 @@ +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + ApiSessionAgentController, + ApiSessionCwdConflict, + ApiSessionNotFound, + ApiSessionSubagentOwnership, + inspectApiSession, +} from '../src/agent.ts' +import { installModelSelectionProjection } from '../src/model-selection-projection.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +async function harness(): Promise<{ ctx: Context; agents: ApiSessionAgentController }> { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + installModelSelectionProjection(ctx) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + return { ctx, agents: new ApiSessionAgentController(ctx) } +} + +function header(id: string, cwd: string | null = '/workspace'): SessionHeader { + return { + version: 0, + id: SessionId(id), + createdAt: 1, + ...(cwd === null ? {} : { cwd }), + } +} + +function providePersistence(ctx: Context, persistence: Record): () => void { + return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) +} + +function agent(ctx: Context, meta: SessionHeader): Agent { + const session = ctx.sessions.create(meta.id, { meta }) + return { id: meta.id, session, status: 'idle', ctx } as Agent +} + +function unpublishedAgent(ctx: Context, meta: SessionHeader): Agent { + return { + id: meta.id, + session: { id: meta.id, header: meta, events: [] }, + status: 'idle', + ctx, + } as unknown as Agent +} + +describe('ApiSession identity failures', () => { + it('describes cwd conflicts with and without a recorded cwd', () => { + expect(new ApiSessionCwdConflict(SessionId('missing-cwd'), '/wanted', undefined).message) + .toContain('records no cwd') + expect(new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/wanted', '/existing').message) + .toContain('belongs to "/existing"') + }) + + it('maps absent and cwd-less point observations to not found', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + await expect(inspectApiSession(ctx, SessionId('missing'))) + .rejects.toBeInstanceOf(ApiSessionNotFound) + + const inspect = vi.fn(() => Promise.resolve(undefined)) + const disposeMissing = providePersistence(ctx, { + list: () => Promise.resolve([]), + inspect, + }) + await expect(inspectApiSession(ctx, SessionId('missing'))).rejects.toBeInstanceOf(ApiSessionNotFound) + expect(inspect).toHaveBeenCalledOnce() + disposeMissing() + + const listed = header('cwd-less-catalog', null) + const disposeListed = providePersistence(ctx, { + list: () => Promise.resolve([listed]), + inspect: () => Promise.resolve({ meta: listed, events: [] }), + }) + await expect(inspectApiSession(ctx, listed.id)).rejects.toBeInstanceOf(ApiSessionNotFound) + disposeListed() + + const catalog = header('cwd-less-inspect') + const inspected = header('cwd-less-inspect', null) + providePersistence(ctx, { + list: () => Promise.resolve([catalog]), + inspect: () => Promise.resolve({ meta: inspected, events: [] }), + }) + await expect(inspectApiSession(ctx, catalog.id)).rejects.toBeInstanceOf(ApiSessionNotFound) + }) + + it('forwards an explicit inspection signal', async () => { + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + const meta = header('signalled-inspection') + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] })) + providePersistence(ctx, { inspect }) + const signal = new AbortController().signal + + await expect(inspectApiSession(ctx, meta.id, signal)).resolves.toEqual({ meta, events: [] }) + expect(inspect).toHaveBeenCalledWith(meta.id, signal) + }) +}) + +describe('ApiSession Agent lookup and recovery', () => { + it('resumes directly from a retained observation and rejects an invalid observed header', async () => { + const { ctx, agents } = await harness() + const meta = header('observed-resume') + const resumed = unpublishedAgent(ctx, meta) + const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ + agent: resumed, + dispose: () => Promise.resolve(), + }) + const observed = { + source: 'prepared', + header: meta, + events: [], + cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation + + await expect(agents.resolveObservedAgent(observed)).resolves.toEqual({ agent: resumed }) + expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) + + const invalid = { + ...observed, + header: header('observed-without-cwd', null), + } as SessionObservation + await expect(agents.resolveObservedAgent(invalid)).resolves.toMatchObject({ + error: { code: 'session-not-found' }, + }) + }) + + it('projects live Agent contexts and maps missing cold identities through Typert lookup failures', async () => { + const { ctx } = await harness() + const live = agent(ctx, header('live')) + ctx.agents.register(live) + providePersistence(ctx, { + list: () => Promise.resolve([]), + inspect: vi.fn(), + }) + const host = ctx.typert.contexts.getHost('agent') + if (host === undefined) throw new Error('Agent Context resolver was not registered') + + await expect(host.resolve(live.id)).resolves.toBe(live.ctx) + await expect(host.resolve(SessionId('missing'))).rejects.toBeInstanceOf(TypertLookupFailure) + }) + + it('returns raced ordinary Agents and ownership failures after resume throws', async () => { + const ordinary = await harness() + const ordinaryMeta = header('ordinary-race') + providePersistence(ordinary.ctx, { + list: () => Promise.resolve([ordinaryMeta]), + inspect: () => Promise.resolve({ meta: ordinaryMeta, events: [] }), + }) + const winner = agent(ordinary.ctx, ordinaryMeta) + vi.spyOn(ordinary.ctx.agents, 'resume').mockImplementation(async () => { + ordinary.ctx.agents.register(winner) + throw new Error('raced publication') + }) + await expect(ordinary.agents.resolveAgent(ordinaryMeta.id)).resolves.toEqual({ agent: winner }) + + const child = await harness() + const childMeta = header('child-race') + providePersistence(child.ctx, { + list: () => Promise.resolve([childMeta]), + inspect: () => Promise.resolve({ meta: childMeta, events: [] }), + }) + vi.spyOn(child.ctx.agents, 'resume').mockImplementation(async () => { + child.ctx.sessions.create(childMeta.id, { + meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + throw new Error('raced child publication') + }) + await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ + error: { code: 'agent-busy' }, + }) + }) + + it('reports not-found and ordinary resume failures without fabricating an Agent', async () => { + const missing = await harness() + providePersistence(missing.ctx, { + list: () => Promise.resolve([]), + inspect: vi.fn(), + }) + await expect(missing.agents.resolveAgent(SessionId('missing'))).resolves.toMatchObject({ + error: { code: 'session-not-found' }, + }) + + const failed = await harness() + const meta = header('failed') + providePersistence(failed.ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] }), + }) + vi.spyOn(failed.ctx.agents, 'resume').mockRejectedValue(new Error('factory unavailable')) + await expect(failed.agents.resolveAgent(meta.id)).resolves.toMatchObject({ + error: { code: 'internal', message: expect.stringContaining('factory unavailable') as string }, + }) + }) + + it('requires projected observations before activation', async () => { + const { agents } = await harness() + const meta = header('unprojected-observation') + const observed = { + source: 'prepared', + header: meta, + events: [], + cursor: -1, + retain: vi.fn(), + [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation + + expect(() => agents.presetForObservation(observed)).toThrow( + 'Agent activation requires a projected Session observation', + ) + }) +}) + +describe('ApiSession model selection', () => { + it('requires the model-selection projection', async () => { + const { ctx, agents } = await harness() + const live = agent(ctx, header('missing-model-projection')) + vi.spyOn(ctx.sessionProjections, 'stateOf').mockReturnValue(undefined) + + expect(() => agents.selectionFor(live)).toThrow('required modelSelection projection') + }) + + it('reads a reasoning-free request and consumes only the exact pending selection', async () => { + const { ctx, agents } = await harness() + const logged = agent(ctx, header('logged-model')) + logged.session.append('request/header', { + header: { config: { provider: 'logged-provider', model: 'logged-model' } }, + reason: 'initial', + }) + expect(agents.selectionFor(logged).current).toEqual({ + provider: 'logged-provider', + model: 'logged-model', + }) + + const pending = agent(ctx, header('pending-model')) + const selection = agents.selectionFor(pending) + agents.selectForNextRequest(pending, { + provider: 'selected-provider', + model: 'selected-model', + reasoningEffort: 'high' as never, + }) + expect(selection.current).toMatchObject({ + provider: 'selected-provider', model: 'selected-model', reasoningEffort: 'high', + }) + expect(agents.consumeSelection(pending, 'other-provider', 'selected-model', 'high')).toBe(false) + expect(agents.consumeSelection(pending, 'selected-provider', 'other-model', 'high')).toBe(false) + expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'low')).toBe(false) + expect(agents.consumeSelection(pending, 'selected-provider', 'selected-model', 'high')).toBe(true) + expect(selection.current).toEqual({ provider: 'fixture', model: 'fixture-model' }) + + const untouched = agent(ctx, header('uninstalled-model')) + expect(agents.consumeSelection(untouched, 'fixture', 'fixture-model', undefined)).toBe(false) + }) +}) + +describe('ApiSession create or adoption', () => { + it('shares one in-flight creation between concurrent callers', async () => { + const { ctx, agents } = await harness() + const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-concurrent-')) + const meta = header('concurrent-create', cwd) + const created = unpublishedAgent(ctx, meta) + let release!: () => void + const gate = new Promise((resolve) => { release = resolve }) + const create = vi.spyOn(ctx.agents, 'create').mockImplementation(async () => { + await gate + return { agent: created, dispose: () => Promise.resolve() } + }) + + const first = agents.ensureSession(meta.id, cwd, false) + const second = agents.ensureSession(meta.id, cwd, false) + release() + + await expect(Promise.all([first, second])).resolves.toEqual([created, created]) + expect(create).toHaveBeenCalledOnce() + }) + + it('accepts a raced ordinary creation and rejects a raced attached child', async () => { + const ordinary = await harness() + const cwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-create-')) + const ordinaryMeta = header('create-race', cwd) + const winner = agent(ordinary.ctx, ordinaryMeta) + vi.spyOn(ordinary.ctx.agents, 'create').mockImplementation(async () => { + ordinary.ctx.agents.register(winner) + throw new Error('raced creation') + }) + await expect(ordinary.agents.ensureSession(ordinaryMeta.id, cwd, false)) + .resolves.toBe(winner) + + const child = await harness() + const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-child-')) + const childId = SessionId('create-child-race') + vi.spyOn(child.ctx.agents, 'create').mockImplementation(async () => { + child.ctx.sessions.create(childId, { + meta: { cwd: childCwd, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + throw new Error('raced child creation') + }) + await expect(child.agents.ensureSession(childId, childCwd, false)) + .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) + }) + + it('validates ownership and cwd on the Agent returned by creation', async () => { + const child = await harness() + const childCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-returned-child-')) + const childMeta = { + ...header('returned-child', childCwd), + parentSession: SessionId('parent'), + origin: 'subagent' as const, + } + const childAgent = unpublishedAgent(child.ctx, childMeta) + vi.spyOn(child.ctx.agents, 'create').mockResolvedValue({ + agent: childAgent, + dispose: () => Promise.resolve(), + }) + await expect(child.agents.ensureSession(childMeta.id, childCwd, false)) + .rejects.toBeInstanceOf(ApiSessionSubagentOwnership) + + const wrong = await harness() + const requestedCwd = mkdtempSync(join(tmpdir(), 'dsh-session-controller-wrong-cwd-')) + const wrongAgent = unpublishedAgent(wrong.ctx, header('wrong-returned-cwd', '/other')) + vi.spyOn(wrong.ctx.agents, 'create').mockResolvedValue({ + agent: wrongAgent, + dispose: () => Promise.resolve(), + }) + await expect(wrong.agents.ensureSession(wrongAgent.id, requestedCwd, false)) + .rejects.toBeInstanceOf(ApiSessionCwdConflict) + }) + + it('resumes a matching persisted identity and preserves its selected preset', async () => { + const { ctx, agents } = await harness() + const meta = { ...header('stored'), agentPreset: 'minimal' } + const events = [{ + type: 'agent-preset/selected', + seq: 0, + time: 1, + data: { agentPreset: 'minimal' }, + }] as SessionEvent[] + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + }) + ctx.provide('agentPresets', { + resolve: (id?: string) => Promise.resolve({ id: id ?? 'minimal' }), + mount: () => Promise.resolve(), + } as never) + const resumed = { + id: meta.id, + session: { id: meta.id, header: meta, events }, + status: 'idle', + ctx, + } as unknown as Agent + const resume = vi.spyOn(ctx.agents, 'resume').mockResolvedValue({ + agent: resumed, + dispose: () => Promise.resolve(), + }) + + await expect(agents.ensureSession(meta.id, '/workspace', true, 'minimal')).resolves.toBe(resumed) + expect(resume).toHaveBeenCalledWith(expect.objectContaining({ resumeSessionId: meta.id })) + }) + + it('rejects an ownership race before resume and a persisted cwd conflict', async () => { + const child = await harness() + const childMeta = header('resume-child-race') + providePersistence(child.ctx, { + list: () => Promise.resolve([childMeta]), + inspect: () => Promise.resolve({ meta: childMeta, events: [] }), + }) + child.ctx.provide('agentPresets', { + resolve: () => { + child.ctx.sessions.create(childMeta.id, { + meta: { ...childMeta, parentSession: SessionId('parent'), origin: 'subagent' }, + }) + return Promise.resolve({ id: 'standard' }) + }, + mount: () => Promise.resolve(), + } as never) + await expect(child.agents.resolveAgent(childMeta.id)).resolves.toMatchObject({ + error: { code: 'agent-busy' }, + }) + + const conflict = await harness() + const stored = header('stored-cwd-conflict', '/stored') + providePersistence(conflict.ctx, { + list: () => Promise.resolve([stored]), + inspect: () => Promise.resolve({ meta: stored, events: [] }), + }) + await expect(conflict.agents.ensureSession(stored.id, '/requested', true)) + .rejects.toBeInstanceOf(ApiSessionCwdConflict) + }) + + it('surfaces directory creation failure and rejects setup without a scoped Agent', async () => { + const { agents } = await harness() + const parent = mkdtempSync(join(tmpdir(), 'dsh-session-controller-file-')) + const file = join(parent, 'file') + writeFileSync(file, 'not a directory') + await expect(agents.ensureSession(SessionId('mkdir-failure'), join(file, 'child'), false)) + .rejects.toThrow('failed to ensure project directory') + + const composition = await agents.composeAgent(undefined) + expect(() => composition.setup(new Context())).toThrow('Agent setup has no scoped Agent') + }) +}) diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts new file mode 100644 index 0000000000..876786a759 --- /dev/null +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -0,0 +1,219 @@ +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' +import type { + ConnectionHandle, + HostDescription, +} from '@deepseek-ai/dsh-client-connection/client' +import { + RemoteStreamCarrierError, + RemoteStream, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { afterEach, describe, expect, it, vi } from 'vitest' +import * as SessionClient from '../src/client/index.ts' +import { ClientSessions } from '../src/client/sessions/service.ts' +import { FakeApiClient, fakeRemote } from './fake-api.client.ts' + +const DESCRIPTION: HostDescription = { + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, +} + +const sid = (value: string): SessionId => value as SessionId + +type RemoteListener = (...args: never[]) => void + +interface Bench { + readonly ctx: Context + readonly api: FakeApiClient + readonly fiber: Fiber + readonly sessions: ClientSessions + dispatch(event: string, ...args: unknown[]): void + publishHost(description: HostDescription | undefined): void +} + +const contexts = new Set() + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all([...contexts].map(async (ctx) => { await ctx.fiber.dispose() })) + contexts.clear() +}) + +async function mount(initialHost?: HostDescription): Promise { + const ctx = new Context() + contexts.add(ctx) + await ctx.plugin(TypertRegistry) + const api = new FakeApiClient() + const remote = fakeRemote(api) + const listeners = new Map>() + const hostListeners = new Set<() => void>() + let host = initialHost + const connection: ConnectionHandle = { + api, + isLoopback: true, + hostDescription: { + getSnapshot: () => host, + subscribe: (listener) => { + hostListeners.add(listener) + return () => { hostListeners.delete(listener) } + }, + }, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + ctx.reflect.provide('connection', connection) + ctx.reflect.provide('remote', { + ...remote, + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(connection, options) + ), + $on: (event: string, listener: RemoteListener) => { + const eventListeners = listeners.get(event) ?? new Set() + eventListeners.add(listener) + listeners.set(event, eventListeners) + return () => { eventListeners.delete(listener) } + }, + }) + ctx.reflect.provide('remote.commands', remote.commands) + ctx.reflect.provide('remote.session', remote.session) + ctx.reflect.provide('remote.subagents', remote.subagents) + const fiber = ctx.plugin(SessionClient) + await fiber + const sessions = ctx.sessions as ClientSessions + return { + ctx, + api, + fiber, + sessions, + dispatch: (event, ...args) => { + for (const listener of listeners.get(event) ?? []) listener(...args as never[]) + }, + publishHost: (description) => { + host = description + for (const listener of [...hostListeners]) listener() + }, + } +} + +async function flush(): Promise { + for (let index = 0; index < 12; index++) await Promise.resolve() +} + +describe('Session Controller Client apply', () => { + it('routes Session Remote Events and connection generations into the object layer', async () => { + const connected = vi.spyOn(ClientSessions.prototype, 'handleConnected') + const error = vi.spyOn(ClientSessions.prototype, 'handleSessionError') + const bench = await mount() + expect(connected).not.toHaveBeenCalled() + + bench.dispatch('api-session/added', { + sessionId: sid('session-1'), + updatedAt: 1, + running: false, + blank: true, + }) + await flush() + expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ + running: false, + updatedAt: 1, + }) + + bench.dispatch('api-session/status', sid('session-1'), true) + bench.dispatch('api-session/activity', sid('session-1'), 9) + bench.dispatch('api-session/error', sid('session-1'), 'agent failed') + await flush() + expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toMatchObject({ + running: true, + updatedAt: 9, + }) + expect(error).toHaveBeenCalledWith(sid('session-1'), 'agent failed') + + bench.dispatch('api-session/removed', sid('session-1')) + await flush() + expect(bench.sessions.list.getSnapshot().byId[sid('session-1')]).toBeUndefined() + + bench.ctx.emit('connection/reset') + expect(connected).toHaveBeenCalledOnce() + }) + + it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { + const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') + const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) + const bench = await mount(DESCRIPTION) + await flush() + + expect(accept).toHaveBeenCalledWith({ + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + }) + + bench.api.failStreams(new RemoteStreamCarrierError('generation lost')) + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) + + bench.api.pushControl({ type: 'baseline', value: bench.api.controlBaseline } as never) + await vi.waitFor(() => { + expect(logged).toHaveBeenCalledWith( + '[session-controller] control stream failed:', + expect.objectContaining({ message: 'session control stream emitted more than one opening snapshot' }), + ) + }) + }) + + it('materializes Host-addressed Agent scopes before the Session list arrives', async () => { + const bench = await mount() + const adapter = bench.ctx.typert.contexts.getClient('agent') + const first = adapter?.resolve(sid('agent-early')) + + expect(first).toBeDefined() + expect(bench.sessions.scopeOf(first as Context)).toBe(sid('agent-early')) + expect(adapter?.resolve(sid('agent-early'))).toBe(first) + }) + + it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { + const bench = await mount(DESCRIPTION) + await flush() + expect(bench.sessions.list.getSnapshot().phase).toBe('ready') + + bench.dispatch('api-session/added', { + sessionId: sid('agent-1'), + updatedAt: 1, + running: false, + blank: true, + }) + await flush() + const scoped = bench.sessions.scope(sid('agent-1')) + const adapter = bench.ctx.typert.contexts.getClient('agent') + expect(scoped).toBeDefined() + expect(adapter?.identity(bench.ctx)).toBeUndefined() + expect(adapter?.identity(scoped!)).toBe(sid('agent-1')) + expect(adapter?.resolve(sid('agent-1'))).toBe(scoped) + + await bench.fiber.dispose() + expect(bench.ctx.typert.contexts.getClient('agent')).toBeUndefined() + }) + + it('waits for a Host generation before retrying the control stream', async () => { + const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') + const bench = await mount() + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + + bench.api.failStreams(new RemoteStreamCarrierError('offline')) + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) + + bench.publishHost(DESCRIPTION) + await flush() + expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) + }) +}) diff --git a/packages/api/session-controller/tests/client-contract.client.spec.ts b/packages/api/session-controller/tests/client-contract.client.spec.ts new file mode 100644 index 0000000000..789fe04d6d --- /dev/null +++ b/packages/api/session-controller/tests/client-contract.client.spec.ts @@ -0,0 +1,89 @@ +import { describe, expect, it, vi } from 'vitest' +import { + MutableSessionEventSource, type SessionLiveEventEntry, +} from '../src/client/contract/events.ts' +import { transportResult } from '../src/client/contract/result.ts' + +function entry(seq: number): SessionLiveEventEntry { + return { + type: 'event', + event: { + type: 'turn/start', + seq, + time: seq, + data: { turn: seq }, + }, + } +} + +describe('Client Session contracts', () => { + it('publishes exact replace, prepend, and append event-window changes', () => { + const feed = new MutableSessionEventSource() + const listener = vi.fn() + const dispose = feed.subscribe(listener) + const first = entry(1) + const older = entry(0) + const live = entry(2) + + feed.replace([first], true) + expect(feed.getSnapshot()).toEqual({ + entries: [first], + hasMore: true, + revision: 1, + change: { kind: 'replace', entries: [first] }, + }) + + feed.prepend([older], false) + expect(feed.getSnapshot()).toEqual({ + entries: [older, first], + hasMore: false, + revision: 2, + change: { kind: 'prepend', entries: [older] }, + }) + + feed.append(live) + expect(feed.getSnapshot()).toEqual({ + entries: [older, first, live], + hasMore: false, + revision: 3, + change: { kind: 'append', entries: [live] }, + }) + expect(listener).toHaveBeenCalledTimes(3) + + dispose() + feed.append(entry(3)) + expect(listener).toHaveBeenCalledTimes(3) + }) + + it('does not traverse the complete event window while appending', () => { + const feed = new MutableSessionEventSource() + const first = entry(1) + const base = [first] + const iterate = vi.fn(Array.prototype[Symbol.iterator].bind(base)) + Object.defineProperty(base, Symbol.iterator, { value: iterate }) + feed.replace(base, false) + iterate.mockClear() + + const before = feed.getSnapshot() + const live = entry(2) + feed.append(live) + const after = feed.getSnapshot() + + expect(iterate).not.toHaveBeenCalled() + expect(before.entries).toEqual([first]) + expect(after.entries).toEqual([first, live]) + expect(after.entries).toBe(after.entries) + expect(iterate).toHaveBeenCalledOnce() + }) + + it('folds Error and non-Error carrier rejections into Client failures', () => { + expect(transportResult(new Error('transport unavailable'))).toEqual({ + ok: false, + error: { code: 'internal', message: 'transport unavailable', details: {} }, + }) + expect(transportResult(404)).toEqual({ + ok: false, + error: { code: 'internal', message: '404', details: {} }, + }) + }) +}) diff --git a/packages/api/session-controller/tests/commands-create-fork.host.spec.ts b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts new file mode 100644 index 0000000000..88b1450970 --- /dev/null +++ b/packages/api/session-controller/tests/commands-create-fork.host.spec.ts @@ -0,0 +1,280 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { PresetMountError } from '@deepseek-ai/dsh-agent-presets' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Workspace, WorkspaceId } from '@deepseek-ai/dsh-workspace' +import { describe, expect, it, vi } from 'vitest' +import { + ApiSessionAgentController, + ApiSessionCwdConflict, +} from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' + +async function expectFailure(operation: Promise, code: string): Promise { + await expect(operation).rejects.toMatchObject({ failure: { code } }) +} + +function controllerAgents(overrides: object = {}): ApiSessionAgentController { + return { + ensureSession: () => Promise.resolve(), + composeAgent: () => Promise.resolve({ setup: () => {} }), + presetForSession: () => undefined, + presetForObservation: () => undefined, + ...overrides, + } as unknown as ApiSessionAgentController +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + return ctx +} + +describe('Session creation failures', () => { + it('mints an identity with the default cwd when no explicit target is supplied', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const ensureSession = vi.fn((sessionId: SessionId, cwd: string) => { + const session = ctx.sessions.create(sessionId, { meta: { cwd } }) + return Promise.resolve({ id: sessionId, session } as Agent) + }) + const controller = new SessionCommandController( + ctx, + controllerAgents({ ensureSession }), + '/default-workspace', + ) + + const created = await controller.create({}) + + expect(created.sessionId).toMatch(/^session-/) + expect(created).not.toHaveProperty('agentPreset') + expect(ensureSession).toHaveBeenCalledWith( + created.sessionId, + '/default-workspace', + false, + undefined, + ) + await ctx.fiber.dispose() + }) + + it('maps missing Workspaces and attachment failures', async () => { + const missing = await baseContext() + missing.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const missingController = new SessionCommandController( + missing, + controllerAgents(), + '/default', + ) + await expectFailure(missingController.create({ + workspaceId: 'missing' as WorkspaceId, + }), 'workspace-not-found') + await missing.fiber.dispose() + + const failed = await baseContext() + const workspace = { + id: 'workspace-1' as WorkspaceId, + path: '/workspace', + attachSession: () => Promise.reject(new Error('read-only workspace')), + } as unknown as Workspace + failed.provide('workspaceRegistry', { + get: () => workspace, + list: () => [workspace], + } as never) + const failedController = new SessionCommandController( + failed, + controllerAgents(), + '/default', + ) + await expectFailure(failedController.create({ + sessionId: SessionId('workspace-session'), + workspaceId: workspace.id, + }), 'workspace-attach-failed') + await failed.fiber.dispose() + }) + + it.each([ + { + error: new PresetMountError('broken', 'invalid composition'), + code: 'agent-preset-invalid', + }, + { + error: new ApiSessionCwdConflict(SessionId('cwd-less'), '/requested', undefined), + code: 'session-conflict', + }, + { + error: new ApiSessionCwdConflict(SessionId('wrong-cwd'), '/requested', '/stored'), + code: 'session-conflict', + }, + { + error: new Error('factory unavailable'), + code: 'internal', + }, + ])('maps $code creation failures', async ({ error, code }) => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + const controller = new SessionCommandController( + ctx, + controllerAgents({ ensureSession: () => Promise.reject(error) }), + '/default', + ) + + await expectFailure(controller.create({ + sessionId: SessionId('failed-create'), cwd: '/requested', + }), code) + await ctx.fiber.dispose() + }) + + it('rejects contradictory create targets', async () => { + const ctx = await baseContext() + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.create({ + workspaceId: 'workspace-1' as WorkspaceId, + cwd: '/workspace', + }), 'bad-request') + await ctx.fiber.dispose() + }) + +}) + +function completedSession( + ctx: Context, + id: string, + cwd?: string, + lineage: { parentSession?: SessionId; origin?: 'subagent' } = {}, +) { + const session = ctx.sessions.create(SessionId(id), { + meta: { ...(cwd === undefined ? {} : { cwd }), ...lineage }, + }) + session.append('turn/start', { turn: 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'work' }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + return session +} + +function resolvedHandle(ctx: Context, sessionId: SessionId): AgentHandle { + return { + agent: { id: sessionId, status: 'idle', ctx } as Agent, + dispose: () => Promise.resolve(), + } +} + +describe('Session fork failures', () => { + it('maps missing cold sources with and without persistence', async () => { + const withoutPersistence = await baseContext() + withoutPersistence.provide('workspaceRegistry', { list: () => [] } as never) + const unavailableController = new SessionCommandController( + withoutPersistence, controllerAgents(), '/default', + ) + await expectFailure(unavailableController.fork({ + sessionId: SessionId('missing'), + }), 'session-not-found') + await withoutPersistence.fiber.dispose() + + const missing = await baseContext() + missing.provide('workspaceRegistry', { list: () => [] } as never) + missing.provide('sessionPersistence', testSessionPersistence(missing, { + list: () => Promise.resolve([]), + inspect: vi.fn(), + }) as never) + const missingController = new SessionCommandController(missing, controllerAgents(), '/default') + await expectFailure(missingController.fork({ + sessionId: SessionId('missing'), + }), 'session-not-found') + await missing.fiber.dispose() + }) + + it('maps an observation failure to an internal fork error', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { list: () => [] } as never) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.fork({ sessionId: SessionId('unreadable') }), 'internal') + await ctx.fiber.dispose() + }) + + it('rejects a Session with no completed turn', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { list: () => [] } as never) + const source = ctx.sessions.create(SessionId('empty-source')) + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.fork({ sessionId: source.id }), 'fork-unavailable') + await ctx.fiber.dispose() + }) + + it('maps lineage lookup and Agent creation failures', async () => { + const lineage = await baseContext() + lineage.provide('workspaceRegistry', { list: () => [] } as never) + vi.spyOn(lineage.sessionQuery, 'traceSession') + .mockRejectedValue(new Error('lineage unavailable')) + const child = completedSession(lineage, 'subagent-source', '/workspace', { + parentSession: SessionId('parent'), + origin: 'subagent', + }) + const lineageController = new SessionCommandController(lineage, controllerAgents(), '/default') + await expectFailure(lineageController.fork({ sessionId: child.id }), 'internal') + await lineage.fiber.dispose() + + const creation = await baseContext() + creation.provide('workspaceRegistry', { list: () => [] } as never) + const source = completedSession(creation, 'creation-source', '/workspace') + vi.spyOn(creation.agents, 'create').mockRejectedValue(new Error('factory failed')) + const creationController = new SessionCommandController(creation, controllerAgents(), '/default') + await expectFailure(creationController.fork({ sessionId: source.id }), 'internal') + await creation.fiber.dispose() + }) + + it('omits absent cwd and preset metadata before reporting Workspace attachment failure', async () => { + const ctx = await baseContext() + const source = completedSession(ctx, 'workspace-source') + const workspace = { + id: 'workspace-1' as WorkspaceId, + sessionIds: [source.id], + attachSession: () => Promise.reject(new Error('workspace write failed')), + } as unknown as Workspace + ctx.provide('workspaceRegistry', { list: () => [workspace] } as never) + const create = vi.spyOn(ctx.agents, 'create').mockImplementation( + (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), + ) + const controller = new SessionCommandController(ctx, controllerAgents(), '/default') + + await expectFailure(controller.fork({ sessionId: source.id }), 'workspace-attach-failed') + const options = create.mock.calls[0]?.[0] + if (options === undefined) throw new Error('Agent creation was not attempted') + expect(options.meta).not.toHaveProperty('cwd') + expect(options.meta).not.toHaveProperty('agentPreset') + await ctx.fiber.dispose() + }) + + it('carries the composed Agent preset into the child metadata', async () => { + const ctx = await baseContext() + ctx.provide('workspaceRegistry', { list: () => [] } as never) + const source = completedSession(ctx, 'preset-source', '/workspace') + const create = vi.spyOn(ctx.agents, 'create').mockImplementation( + (options: CreateAgentOptions) => Promise.resolve(resolvedHandle(ctx, options.sessionId)), + ) + const controller = new SessionCommandController(ctx, controllerAgents({ + composeAgent: () => Promise.resolve({ agentPreset: 'minimal', setup: () => {} }), + }), '/default') + + const forked = await controller.fork({ sessionId: source.id }) + expect(forked.sessionId).toMatch(/^session-/) + const options = create.mock.calls[0]?.[0] + if (options === undefined) throw new Error('Agent creation was not attempted') + expect(options.meta?.agentPreset).toBe('minimal') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts new file mode 100644 index 0000000000..2ea8744bd1 --- /dev/null +++ b/packages/api/session-controller/tests/commands-queue-attachment.host.spec.ts @@ -0,0 +1,263 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import { createAssistantMessage, createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { describe, expect, it, vi } from 'vitest' +import { ApiSessionAgentController } from '../src/agent.ts' +import { SessionCommandController } from '../src/commands.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' + +async function commandHarness(): Promise<{ + ctx: Context + controller: SessionCommandController + agent: Agent + inbox: Inbox + steer: ReturnType + cancel: ReturnType +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(SessionId('commands-session'), { meta: { cwd: '/workspace' } }) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const steer = vi.fn() + const cancel = vi.fn() + const agent = { + id: session.id, + session, + inbox, + status: 'running', + ctx, + steer, + followup: vi.fn(), + cancel, + } as unknown as Agent + ctx.agents.register(agent) + ctx.provide('workspaceRegistry', { get: () => undefined, list: () => [] } as never) + ctx.provide('agentDefaultModel', { + currentSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + saveSelection: () => Promise.resolve(), + } as never) + const selection: ModelSelectionRef = { + current: { provider: 'fixture', model: 'fixture-model' }, + assembled: undefined, + } + const agents = { + resolveAgent: () => Promise.resolve({ agent }), + selectionFor: () => selection, + serializeImageAdmission: (_agent: Agent, operation: () => Promise) => operation(), + composeAgent: () => Promise.resolve({ setup: () => {} }), + } as unknown as ApiSessionAgentController + return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), agent, inbox, steer, cancel } +} + +async function expectFailure(operation: Promise, code: string): Promise { + await expect(operation).rejects.toMatchObject({ failure: { code } }) +} + +describe('Session queue commands', () => { + it('edits, removes, steers, and rejects stale queue occurrences', async () => { + const { ctx, controller, agent, inbox, steer, cancel } = await commandHarness() + const queued = createUserMessage({ content: [{ type: 'text', text: 'queued' }], source: { kind: 'user' } }) + const nextStep = createUserMessage({ content: [{ type: 'text', text: 'step' }], source: { kind: 'user' } }) + inbox.append('next-turn', queued) + inbox.append('next-step', nextStep) + + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, + itemId: queued.id, + action: { + kind: 'edit', + content: [{ + type: 'image', + attachment: { + attachmentId: AttachmentId('att-edit'), mediaType: 'image/png', bytes: 1, width: 1, height: 1, + }, + }], + }, + })), 'attachment-error') + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: SessionId('missing'), itemId: queued.id, action: { kind: 'remove' }, + })), 'queue-item-not-found') + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, itemId: MessageId('missing'), action: { kind: 'remove' }, + })), 'queue-item-not-found') + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, itemId: nextStep.id, action: { kind: 'steer' }, + })), 'steer-unavailable') + + Object.assign(agent, { status: 'idle' }) + await expectFailure(Promise.resolve().then(() => controller.updateQueue({ + sessionId: agent.id, itemId: queued.id, action: { kind: 'steer' }, + })), 'steer-unavailable') + expect(controller.updateQueue({ + sessionId: agent.id, + itemId: queued.id, + action: { kind: 'edit', content: [{ type: 'text', text: 'edited' }] }, + })).toEqual({ accepted: true }) + expect(inbox.nextTurn[0]?.content).toEqual([{ type: 'text', text: 'edited' }]) + expect(controller.updateQueue({ + sessionId: agent.id, itemId: nextStep.id, action: { kind: 'remove' }, + })).toEqual({ accepted: true }) + + Object.assign(agent, { status: 'running' }) + const steered = inbox.nextTurn[0] + if (steered === undefined) throw new Error('missing edited queue item') + expect(controller.updateQueue({ + sessionId: agent.id, itemId: steered.id, action: { kind: 'steer' }, + })).toEqual({ accepted: true }) + expect(steer).toHaveBeenCalledWith(steered) + + await expectFailure(Promise.resolve().then(() => controller.cancel({ + sessionId: SessionId('missing'), + })), 'session-not-found') + expect(controller.cancel({ sessionId: agent.id })).toEqual({ accepted: true }) + expect(cancel).toHaveBeenCalledWith({ kind: 'user' }, { keepInbox: true }) + await ctx.fiber.dispose() + }) +}) + +function imageRef(id: string): ImageAttachmentRef { + return { + attachmentId: AttachmentId(id), + mediaType: 'image/png', + bytes: 1, + width: 1, + height: 1, + } +} + +function event(type: string, seq: number, data: unknown): SessionEvent { + return { type, seq, time: seq + 1, data } as SessionEvent +} + +async function persistedController( + events: SessionEvent[], + readImage: (ref: ImageAttachmentRef) => Promise<{ ref: ImageAttachmentRef; data: Uint8Array }>, +): Promise<{ ctx: Context; controller: SessionCommandController; sessionId: SessionId }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('cold-attachment') + const meta: SessionHeader = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + }) as never) + installSessionReadTestServices(ctx) + ctx.provide('attachments', { readImage } as never) + const agents = { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController + return { ctx, controller: new SessionCommandController(ctx, agents, '/workspace'), sessionId } +} + +describe('Session attachment authorization', () => { + it('finds references in direct, message, inserted, nested, and streamed content', async () => { + const nested = imageRef('nested') + const message = imageRef('message') + const inserted = imageRef('inserted') + const streamed = imageRef('streamed') + const events = [ + event('fixture/direct', 0, { + content: [null, [], { type: 'tool-result', content: [{ type: 'text', text: 'none' }] }, { + type: 'tool-result', content: [{ type: 'image', attachment: nested }], + }], + }), + { ...event('assistant/message', 1, { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'image', attachment: message }], + source: { provider: 'fixture', model: 'fixture' }, + }), + }), surfaceOp: 'append' as const }, + event('agent/inbox/spliced', 2, { + target: 'next-turn', + start: 0, + inserted: [createUserMessage({ + content: [{ type: 'image', attachment: inserted }], + source: { kind: 'user' }, + })], + }), + event('assistant/chunk', 3, { + turn: 1, + step: 1, + chunk: { type: 'block-end', index: 0, block: { type: 'image', attachment: streamed } }, + }), + ] + const readImage = vi.fn((ref: ImageAttachmentRef) => Promise.resolve({ ref, data: Uint8Array.of(1) })) + const { ctx, controller, sessionId } = await persistedController(events, readImage) + + for (const ref of [nested, message, inserted, streamed]) { + await expect(controller.attachment({ sessionId, attachmentId: ref.attachmentId })) + .resolves.toEqual({ attachment: ref, data: 'AQ==' }) + } + expect(readImage).toHaveBeenCalledTimes(4) + await ctx.fiber.dispose() + }) + + it('maps missing persistence identities and attachment backend failures', async () => { + const noPersistence = new Context() + await noPersistence.plugin(SessionStore) + installSessionReadTestServices(noPersistence) + const noPersistenceController = new SessionCommandController( + noPersistence, + { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, + '/workspace', + ) + await expectFailure(noPersistenceController.attachment({ + sessionId: SessionId('missing'), attachmentId: AttachmentId('att'), + }), 'session-not-found') + + const missing = new Context() + await missing.plugin(SessionStore) + missing.provide('sessionPersistence', testSessionPersistence(missing, { + list: () => Promise.resolve([]), + inspect: vi.fn(), + }) as never) + installSessionReadTestServices(missing) + const missingController = new SessionCommandController( + missing, + { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, + '/workspace', + ) + await expectFailure(missingController.attachment({ + sessionId: SessionId('missing'), attachmentId: 'att' as never, + }), 'session-not-found') + + for (const thrown of [ + new AttachmentError('stored image is unavailable', 'ATTACHMENT_NOT_FOUND'), + new Error('backend offline'), + ]) { + const ref = imageRef(`failure-${thrown.name}`) + const fixture = await persistedController( + [event('fixture/content', 0, { content: [{ type: 'image', attachment: ref }] })], + () => Promise.reject(thrown), + ) + await expectFailure(fixture.controller.attachment({ + sessionId: fixture.sessionId, + attachmentId: ref.attachmentId, + }), thrown instanceof AttachmentError ? 'attachment-error' : 'internal') + await fixture.ctx.fiber.dispose() + } + }) + + it('maps a cold observation failure to an internal authorization error', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockRejectedValue(new Error('storage offline')) + const controller = new SessionCommandController( + ctx, + { resolveAgent: vi.fn() } as unknown as ApiSessionAgentController, + '/workspace', + ) + + await expectFailure(controller.attachment({ + sessionId: SessionId('unreadable'), attachmentId: AttachmentId('att'), + }), 'internal') + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/control-jobs.host.spec.ts b/packages/api/session-controller/tests/control-jobs.host.spec.ts new file mode 100644 index 0000000000..f83fa1b3c8 --- /dev/null +++ b/packages/api/session-controller/tests/control-jobs.host.spec.ts @@ -0,0 +1,226 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { JobOutcome } from '@deepseek-ai/dsh-jobs' +import LocalJobRegistry from '@deepseek-ai/dsh-jobs-local' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' +import { SessionControlController } from '../src/control.ts' +import type { SessionControlFrame } from '../src/types.ts' + +type BaselineFrame = Extract +type JobFrame = Extract + +function producer(label = 'sleep 60') { + let settle!: (outcome: JobOutcome) => void + const reads = { count: 0 } + const spec = { + kind: 'bash' as const, + label, + run: () => ({ + cancel: () => {}, + done: new Promise((resolve) => { settle = resolve }), + readOutput: () => { reads.count += 1; return 'stolen output' }, + }), + } + return { spec, reads, settle: (outcome: JobOutcome) => { settle(outcome) } } +} + +async function harness(withRegistry: boolean): Promise<{ + ctx: Context + session: Session + agent: Agent + control: SessionControlController +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + if (withRegistry) { + await ctx.plugin(LocalJobRegistry) + ctx.jobs.attachController('session-controller-test') + } + const session = ctx.sessions.create() + const agent = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + const control = new SessionControlController(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + return { ctx, session, agent, control } +} + +async function baseline(control: SessionControlController): Promise { + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const first = await iterator.next() + abort.abort() + await iterator.next() + if (first.done || first.value.type !== 'baseline') throw new Error('missing control baseline') + return first.value +} + +async function collectJobs( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const jobs: JobFrame[] = [] + for await (const frame of iterable) { + if (frame.type !== 'jobs') continue + jobs.push(frame) + if (jobs.length >= count) abort.abort() + } + return jobs +} + +describe('Session control jobs baseline', () => { + it('represents an attached session with no jobs as an empty set', async () => { + const { session, control } = await harness(true) + const frame = await baseline(control) + expect(frame.value.jobs[session.id]).toEqual([]) + }) + + it('carries the visible set when the stream opens', async () => { + const { ctx, session, agent, control } = await harness(true) + ctx.jobs.start({ ...producer('pnpm run build').spec, owner: agent }) + const frame = await baseline(control) + const jobs = frame.value.jobs[session.id] + expect(jobs).toHaveLength(1) + const [job] = jobs ?? [] + expect(job?.startedAt).toBeTypeOf('number') + expect({ ...job, startedAt: 0 }).toEqual({ + id: 'bash-1', + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 0, + }) + }) +}) + +describe('Session control jobs updates', () => { + it('publishes existing unowned jobs when a Session attaches after the stream opens', async () => { + const { ctx, control } = await harness(true) + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'baseline' } }) + const task = producer('already running') + const id = ctx.jobs.start(task.spec) + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'jobs' } }) + + const created = ctx.sessions.create(SessionId('late-session')) + await expect(iterator.next()).resolves.toMatchObject({ + value: { + type: 'jobs', + sessionId: created.id, + jobs: [expect.objectContaining({ id, label: 'already running' })], + }, + }) + + task.settle({ status: 'completed' }) + abort.abort() + await iterator.return?.() + }) + + it('pushes the owner whole set on registration, stopping, and settlement', async () => { + const { ctx, session, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 3, abort) + + const task = producer() + const id = ctx.jobs.start({ ...task.spec, owner: agent }) + ctx.jobs.kill(id, agent, 'test') + task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + + const frames = await collected + expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) + expect(frames.map(frame => frame.jobs[0]?.status)).toEqual(['running', 'stopping', 'killed']) + expect(frames[2]?.jobs[0]?.detail).toBe('signal: SIGTERM') + expect(frames[2]?.jobs[0]?.finishedAt).toBeTypeOf('number') + }) + + it('drops internal registry fields from the browser view', async () => { + const { ctx, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 1, abort) + ctx.jobs.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) + + const [frame] = await collected + expect(Object.keys(frame?.jobs[0] ?? {}).sort()).toEqual([ + 'id', + 'kind', + 'label', + 'startedAt', + 'status', + ]) + }) + + it('fans an unowned change out to every attached session', async () => { + const { ctx, control } = await harness(true) + const second = ctx.sessions.create() + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 2, abort) + + ctx.jobs.start(producer('open to every caller').spec) + + const frames = await collected + expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) + expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) + for (const frame of frames) expect(frame.jobs[0]?.label).toBe('open to every caller') + }) + + it('does not resume persisted sessions while projecting an unowned change', async () => { + const { ctx, control } = await harness(true) + const coldId = SessionId('session-cold-tasks') + let loaded = false + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load: () => { loaded = true; throw new Error('job projection must not load a cold log') }, + } as never) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 1, abort) + + ctx.jobs.start(producer().spec) + await collected + expect(loaded).toBe(false) + expect(ctx.agents.get(coldId)).toBeUndefined() + }) + + it('reports empty sets when no jobs registry is composed', async () => { + const { session, control } = await harness(false) + const frame = await baseline(control) + expect(frame.value.jobs[session.id]).toEqual([]) + }) + + it('never consumes model output while projecting a lifecycle', async () => { + const { ctx, agent, control } = await harness(true) + const abort = new AbortController() + const collected = collectJobs(control.control(abort.signal), 3, abort) + + const task = producer() + const id = ctx.jobs.start({ ...task.spec, owner: agent }) + ctx.jobs.kill(id, agent, 'test') + task.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + await collected + + expect(task.reads.count).toBe(0) + }) + + it('never consumes model output while producing a baseline', async () => { + const { ctx, agent, control } = await harness(true) + const task = producer() + ctx.jobs.start({ ...task.spec, owner: agent }) + + const frame = await baseline(control) + + expect(frame.value.jobs[agent.id]).toHaveLength(1) + expect(task.reads.count).toBe(0) + }) + +}) diff --git a/packages/api/session-controller/tests/control-queue.host.spec.ts b/packages/api/session-controller/tests/control-queue.host.spec.ts new file mode 100644 index 0000000000..9ce0c453a0 --- /dev/null +++ b/packages/api/session-controller/tests/control-queue.host.spec.ts @@ -0,0 +1,120 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' +import { SessionControlController } from '../src/control.ts' + +async function harness(): Promise<{ + ctx: Context + control: SessionControlController + agent: Agent + inbox: Inbox +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(SessionId('queue-session')) + const inbox = new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }) + const agent = { id: session.id, session, inbox, status: 'running', ctx } as Agent + ctx.agents.register(agent) + return { ctx, control: new SessionControlController(ctx), agent, inbox } +} + +function message(text: string, source: 'user' | 'plugin' = 'user') { + return createUserMessage({ + content: [{ type: 'text', text }], + source: source === 'user' ? { kind: 'user' } : { kind: 'plugin', plugin: 'fixture' }, + }) +} + +describe('Session control queue projection', () => { + it('projects both pending lists in baselines and live replacement frames', async () => { + const { control, inbox } = await harness() + const queued = message('queued') + const steering = message('steering') + const context = message('context', 'plugin') + inbox.append('next-turn', queued) + inbox.append('next-step', steering) + inbox.append('next-step', context) + + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const opened = await iterator.next() + expect(opened.value).toMatchObject({ + type: 'baseline', + value: { + queues: { + 'queue-session': [ + { id: queued.id, placement: 'queued' }, + { id: steering.id, placement: 'steering' }, + { id: context.id, placement: 'context' }, + ], + }, + }, + }) + + const replacement = message('replacement') + inbox.append('next-turn', replacement) + const replaced = await iterator.next() + if (replaced.done || replaced.value.type !== 'queue') throw new Error('missing queue replacement') + expect(replaced.value.items.map(item => item.id)).toContain(replacement.id) + inbox.remove(steering.id) + const removed = await iterator.next() + if (removed.done || removed.value.type !== 'queue') throw new Error('missing queue replacement') + expect(removed.value.items.map(item => item.id)).not.toContain(steering.id) + + abort.abort() + await iterator.next() + }) + + it('ignores inbox events without the exact live Agent session', async () => { + const { ctx, control, agent, inbox } = await harness() + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + + const unrelated = ctx.sessions.create(SessionId('unrelated-queue')) + unrelated.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [message('unrelated')], + }) + const replacement = ctx.sessions.create(SessionId('replacement-session')) + Object.defineProperty(agent, 'session', { configurable: true, value: replacement }) + inbox.append('next-turn', message('wrong-session')) + + abort.abort() + await iterator.next() + }) + + it('drops broadcasts after cancellation has ended its queue', async () => { + const { control, inbox } = await harness() + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const waiting = iterator.next() + await Promise.resolve() + + abort.abort() + inbox.append('next-turn', message('late')) + + await expect(waiting).resolves.toMatchObject({ done: true }) + }) + + it('ends active streams on context disposal after flushing buffered frames', async () => { + const { ctx, control, inbox } = await harness() + const iterator = control.control(new AbortController().signal)[Symbol.asyncIterator]() + await iterator.next() + inbox.append('next-turn', message('first')) + inbox.append('next-turn', message('second')) + + const first = await iterator.next() + expect(first).toMatchObject({ done: false, value: { type: 'queue' } }) + await ctx.fiber.dispose() + const second = await iterator.next() + expect(second).toMatchObject({ done: false, value: { type: 'queue' } }) + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) +}) diff --git a/packages/api/session-controller/tests/controller.host.spec.ts b/packages/api/session-controller/tests/controller.host.spec.ts new file mode 100644 index 0000000000..39a4bf1888 --- /dev/null +++ b/packages/api/session-controller/tests/controller.host.spec.ts @@ -0,0 +1,196 @@ +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import { describe, expect, it, vi } from 'vitest' +import SessionController from '../src/index.ts' +import type { ApiSessionAgentController } from '../src/agent.ts' +import { createSessionTestController, testSessionPersistence } from './test-remote.ts' + +const defaults = { + defaultModelSelection: () => ({ provider: 'fixture', model: 'fixture-model' }), + cwd: '/tmp', +} + +describe('SessionController facade', () => { + it('does not require the Tools service', () => { + expect(SessionController.inject).not.toContain('tools') + }) + + it('owns Host service methods and publishes Agent lifecycle projections', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId('controller-session') + const header: SessionHeader = { + version: 0, + id: sessionId, + createdAt: 1, + cwd: '/workspace', + } + const events: SessionEvent[] = [] + const inspect = vi.fn(() => Promise.resolve({ meta: header, events })) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect, + }) as never) + const controller = createSessionTestController(ctx, defaults) + const status = vi.fn() + const failure = vi.fn() + const activity = vi.fn() + ctx.on('api-session/status', status) + ctx.on('api-session/error', failure) + ctx.on('api-session/activity', activity) + + await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) + expect(inspect).toHaveBeenCalledOnce() + + const session = ctx.sessions.create(sessionId, { meta: header }) + const agent = { + id: sessionId, + session, + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + const consumeSelection = vi.spyOn( + (controller as unknown as { agents: ApiSessionAgentController }).agents, + 'consumeSelection', + ) + + await expect(controller.resolveAgent(sessionId)).resolves.toEqual({ agent }) + await expect(controller.inspect(sessionId)).resolves.toEqual({ meta: header, events }) + expect(inspect).toHaveBeenCalledOnce() + ctx.emit('agent/status', { agent, status: 'running' }) + ctx.emit('agent/error', { agent, turn: 1, step: 0, error: new Error('fixture failure') }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'hello' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + expect(status).toHaveBeenCalledWith(sessionId, true) + expect(failure).toHaveBeenCalledWith(sessionId, expect.stringContaining('fixture failure')) + expect(activity).toHaveBeenCalledWith(sessionId, expect.any(Number)) + session.append('request/header', { + header: { config: { provider: 'fixture', model: 'fixture-model' } }, + reason: 'initial', + }) + expect(consumeSelection).toHaveBeenCalledWith( + agent, 'fixture', 'fixture-model', undefined, + ) + const unowned = ctx.sessions.create(SessionId('controller-unowned'), { + meta: { cwd: '/workspace' }, + }) + unowned.append('request/header', { + header: { config: { provider: 'fixture', model: 'other-model' } }, + reason: 'initial', + }) + expect(consumeSelection).toHaveBeenCalledTimes(1) + + const abort = new AbortController() + const iterator = controller.follow({ + address: { kind: 'session', sessionId }, + }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'snapshot', cursor: 1 }, + }) + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it.each(['success', 'domain-error', 'throw'] as const)( + 'promotes a prepared follow observation in the background: %s', + async (outcome) => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId(`background-${outcome}`) + const header: SessionHeader = { + version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', + } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events: [] }), + }) as never) + const controller = createSessionTestController(ctx, defaults) + const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents + const apiError = vi.fn() + ctx.on('api-session/error', apiError) + const logError = vi.spyOn(ctx.logger, 'error').mockImplementation(() => {}) + const live = { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent + const resolve = vi.spyOn(agents, 'resolveObservedAgent') + if (outcome === 'success') resolve.mockResolvedValue({ agent: live }) + else if (outcome === 'domain-error') { + resolve.mockResolvedValue({ + error: { code: 'internal', message: 'activation unavailable', details: {} }, + }) + } else { + resolve.mockRejectedValue(new Error('activation crashed')) + } + const abort = new AbortController() + const iterator = controller.follow({ + address: { kind: 'session', sessionId }, + }, abort.signal)[Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + const waiting = iterator.next() + await vi.waitFor(() => { expect(resolve).toHaveBeenCalledOnce() }) + if (outcome === 'domain-error') { + await vi.waitFor(() => { + expect(apiError).toHaveBeenCalledWith(sessionId, 'activation unavailable') + }) + } else if (outcome === 'throw') { + await vi.waitFor(() => { + expect(logError).toHaveBeenCalledWith(expect.stringContaining('activation crashed')) + }) + } else { + expect(apiError).not.toHaveBeenCalled() + } + abort.abort() + await expect(waiting).resolves.toMatchObject({ done: true }) + await ctx.fiber.dispose() + }, + ) + + it('waits for an admitted background promotion during teardown', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = SessionId('background-disposal') + const header: SessionHeader = { + version: 0, id: sessionId, createdAt: 1, cwd: '/workspace', + } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events: [] }), + }) as never) + const controller = createSessionTestController(ctx, defaults) + const agents = (controller as unknown as { agents: ApiSessionAgentController }).agents + const started = Promise.withResolvers() + const release = Promise.withResolvers() + vi.spyOn(agents, 'resolveObservedAgent').mockImplementation(async () => { + started.resolve(undefined) + await release.promise + return { + agent: { id: sessionId, session: { id: sessionId }, ctx, status: 'idle' } as unknown as Agent, + } + }) + const iterator = controller.follow({ + address: { kind: 'session', sessionId }, + }, new AbortController().signal)[Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + const waiting = iterator.next() + await started.promise + let disposed = false + const disposal = ctx.fiber.dispose().then(() => { disposed = true }) + await Promise.resolve() + expect(disposed).toBe(false) + + release.resolve(undefined) + await disposal + await expect(waiting).resolves.toMatchObject({ done: true }) + }) +}) diff --git a/packages/client/runtime/tests/event-script.client.ts b/packages/api/session-controller/tests/event-script.client.ts similarity index 89% rename from packages/client/runtime/tests/event-script.client.ts rename to packages/api/session-controller/tests/event-script.client.ts index a024af0ab7..0d8deb5e27 100644 --- a/packages/client/runtime/tests/event-script.client.ts +++ b/packages/api/session-controller/tests/event-script.client.ts @@ -1,8 +1,15 @@ -import { createUserMessage, createMessage, createToolResultMessage, CallId } from '@deepseek-ai/dsh-llm' +import { + ToolCallId, createMessage, createToolResultMessage, createUserMessage, +} from '@deepseek-ai/dsh-llm' // Minimal SessionEvent builders for orchestration tests (shape mirrors what the // host emits; only the fields the object layer reads). import type { ContentBlock } from '@deepseek-ai/dsh-llm/types' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { + SessionEventEntry, + SessionPage, + SessionWireEvent, +} from '../src/types.ts' /** One text content block (local helper). */ const text = (t: string): ContentBlock[] => [{ type: 'text', text: t }] @@ -45,7 +52,7 @@ export const ev = { turn, step, message: createToolResultMessage({ - callId: CallId(callId), + callId: ToolCallId(callId), content: text(body), isError: false, }), @@ -140,7 +147,15 @@ export function plainTurn(startSeq: number, turn: number, ask: string, answer: s ] } -/** Wrap raw events as view-less history entries (the wire shape history returns). */ -export function entries(events: readonly SessionEvent[]): { event: SessionEvent }[] { - return events.map(event => ({ event })) +/** Wrap raw events in the journal envelope returned by history. */ +export function entries(events: readonly SessionEvent[]): SessionEventEntry[] { + return events.map(event => ({ type: 'event', event: event as unknown as SessionWireEvent })) +} + +/** Build one view-less history response value. */ +export function historyValue(events: readonly SessionEvent[], hasMore = false): SessionPage { + return { + records: entries(events), + hasMore, + } } diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts new file mode 100644 index 0000000000..1625ac65e7 --- /dev/null +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -0,0 +1,568 @@ +// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo +// data source on a real clock; behavior tests need per-case responses and +// deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. +import type { + IApiClient, MessageId, + RpcError, RpcResponse, SessionId, SessionSearchItem, SkillEntry, + SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, + WorkspaceId, WorkspaceView, +} from '@deepseek-ai/dsh-api-remotes/client' +import type { + SessionAddress, + SessionControlBaseline, + SessionControlFrame, + SessionFollowFrame, + SessionFollowRequest, + SessionPage, + SessionPageRequest, + SessionProjectionBaseline, + SessionSelectModelRequest, + SessionSelectModelValue, +} from '@deepseek-ai/dsh-api-session-controller/types' +import type { WorkspaceRemote } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { WorkspaceFollowFrame } from '@deepseek-ai/dsh-api-workspace-controller/types' +import type { RemoteFailure, RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { + RemoteStream, + RemoteStreamError, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import { RpcId } from '@deepseek-ai/dsh-client-connection/client' +import type { SessionRemotes } from '../src/client/sessions/remotes.ts' +import { historyRecordLastSeq } from '../src/client/sessions/history-records.ts' + +const AVAILABLE_STREAM_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +/** Programmable-default workspace row (branded id, ISO-ish times). */ +function fakeWorkspace(id: string, over: Partial = {}): WorkspaceView { + return { + workspaceId: id as WorkspaceId, + path: '/f/ws', + title: 'ws', + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...over, + } +} + +function addressSessionId(address: SessionAddress): SessionId { + return address.kind === 'session' ? address.sessionId : address.childSessionId +} + +export interface Deferred { + promise: Promise + resolve(value: T): void + reject(error: unknown): void +} + +/** Test-held settlement: the case decides when an RPC lands (history-pending injections etc.). */ +export function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((res, rej) => { + resolve = res + reject = rej + }) + return { promise, resolve, reject } +} + +let nextRpc = 0 + +export function ok(value: T): RpcResponse { + return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: true, value } } +} + +export function err(error: RpcError): RpcResponse { + return { rpcId: RpcId(`fake-${nextRpc++}`), result: { ok: false, error } } +} + +/** Successful generated Remote result for programmable domain fakes. */ +export function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} + +/** + * Failed generated Remote result carrying an owner's own failure vocabulary, + * which the carrier's closed RPC code set does not contain. + * @param error - the owner-declared failure. + * @returns the failure branch of a Remote result. + */ +export function remoteErr(error: RemoteFailure): RemoteResult { + return { ok: false, error } +} + +type ValueStreamItem = + | { kind: 'frame'; value: F; delivered?: () => void } + | { kind: 'end' } + | { kind: 'fail'; error: unknown } + +interface ValueStreamConn { + feed(item: ValueStreamItem): void +} + +interface OpenValueStream { + readonly values: AsyncGenerator + dispose(): void +} + +/** + * Commands Remote double: the generated face delivers the carrier's outcome, so + * a test that programs nothing sees an empty catalog and an unmatched line. + * @returns the Remote namespaces the session cluster calls. + */ +export type RuntimeRemotes = SessionRemotes & { readonly workspace: WorkspaceRemote } + +export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { + return api.sessionRemotes() +} + +export class FakeApiClient implements IApiClient { + /** Chronological call record: [method, payload]. */ + readonly calls: { method: string; payload: unknown }[] = [] + /** Session ids in physical follow-generation opening order. */ + readonly followStarts: SessionId[] = [] + + // Programmable slots (defaults answer OK-empty); reassign per case. + onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) + onSearch: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ items: [], hasMore: false })) + onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) + onSelectModel: (payload: SessionSelectModelRequest) => Promise> = + payload => Promise.resolve(ok({ + selected: { + provider: payload.provider, + model: payload.model, + ...(payload.reasoningEffort === undefined + ? {} + : { reasoningEffort: payload.reasoningEffort }), + }, + })) + onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) + onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) + onHistory: (payload: { sessionId: SessionId; throughSeq?: number; beforeSeq?: number; maxMessages?: number }) + => Promise> = + () => Promise.resolve(ok({ records: [], hasMore: false })) + + 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==' })) + onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) + 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, home: '/h', canOpenPath: true, + })) + onPickDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: null })) + onOpenPath: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ opened: true as const })) + + onListDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false })) + + onCreateDirectory: (payload: unknown) => Promise> = + () => Promise.resolve(ok({ path: '/home/fake/new' })) + + private readonly followConns = new Map[]>() + private readonly controlConns: ValueStreamConn[] = [] + private readonly workspaceConns: ValueStreamConn[] = [] + /** Optional Host opening cursor override for stale-page and reconnect tests. */ + followCursor: number | undefined + controlBaseline: SessionControlBaseline = { + queues: {}, + jobs: {}, + projections: {}, + } + workspaceBaseline: Extract['value'] = { + items: [], + archivedSessionIds: [], + } + lastSearchSignal: AbortSignal | undefined + + onSubagentList: (payload: unknown) => Promise> + = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) + onSubagentPrompt: (payload: unknown) => Promise> + = () => Promise.resolve(remoteOk({ messageId: 'fake-message' as MessageId })) + + onSubagentInterrupt: (payload: unknown) => Promise> + = () => Promise.resolve(remoteOk({ accepted: true as const })) + + 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)), + listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)), + createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)), + openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)), + } + + onWorkspaceCreate: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) + + onWorkspaceRename: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceDelete: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ deleted: true })) + + onWorkspaceInsertBefore: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspaceIds: [] })) + + onWorkspaceInsertSessionBefore: (payload: unknown) => Promise> = + () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws') })) + + onWorkspaceArchiveSession: (payload: unknown) => Promise> = + payload => Promise.resolve(remoteOk({ archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId] })) + + // 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. + onSkillList: (payload: unknown) => Promise> + = () => Promise.resolve(ok({ skills: [] })) + + + readonly agentPresets: IApiClient['agentPresets'] = { + openDocument: (payload: { agentPreset: string }) => + this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + } + + readonly skills: IApiClient['skills'] = { + list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), + } + + readonly settings: IApiClient['settings'] = { + describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))), + openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), + update: payload => this.record('settings.update', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + replace: payload => this.record('settings.replace', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + mutate: payload => this.record('settings.mutate', payload, Promise.resolve(ok({ ns: 'fake', schema: {}, value: {}, applies: 'live' as const, secrets: [], revision: 0 }))), + } + + readonly credentials: IApiClient['credentials'] = { + describe: payload => this.record('credentials.describe', payload, Promise.resolve(ok({ credentials: {} }))), + set: payload => this.record('credentials.set', payload, Promise.resolve(ok({}))), + unset: payload => this.record('credentials.unset', payload, Promise.resolve(ok({}))), + } + + readonly llm: IApiClient['llm'] = { + providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ + default: { provider: 'fixture', model: 'fixture' }, + routableProviders: [], + groups: [], + failures: [], + }))), + discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), + } + + /** Remote namespaces bound to this fake's programmable unary slots and stream pumps. */ + sessionRemotes(): RuntimeRemotes { + return { + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(AVAILABLE_STREAM_CONNECTION, options) + ), + commands: { + execute: () => Promise.resolve({ ok: true, value: undefined }), + }, + session: { + list: payload => this.remoteResult('session.list', payload, this.onList(payload)), + search: (payload, signal) => { + this.lastSearchSignal = signal + return this.remoteResult('session.search', payload, this.onSearch(payload)) + }, + create: payload => this.remoteResult('session.create', payload, this.onCreate(payload)), + selectModel: payload => this.remoteResult( + 'session.selectModel', + payload, + this.onSelectModel(payload), + ), + rename: payload => this.remoteResult('session.rename', payload, this.onRename(payload)), + fork: payload => this.remoteResult('session.fork', payload, this.onFork(payload)), + prompt: payload => this.remoteResult('session.prompt', payload, this.onPrompt(payload)), + attachment: payload => this.remoteResult('session.attachment', payload, this.onAttachment(payload)), + updateQueue: payload => this.remoteResult('session.updateQueue', payload, this.onUpdateQueue(payload)), + cancel: payload => this.remoteResult('session.cancel', payload, this.onCancel(payload)), + page: request => this.page(request), + follow: (request, signal) => this.openFollow(request, signal), + control: signal => this.openControl(signal), + }, + subagents: { + list: parentSessionId => this.record( + 'subagents.list', + parentSessionId, + this.onSubagentList(parentSessionId), + ), + prompt: request => this.record('subagents.prompt', request, this.onSubagentPrompt(request)), + interruptByParent: (childSessionId, parentSessionId, mode) => this.record( + 'subagents.interruptByParent', + { childSessionId, parentSessionId, mode }, + this.onSubagentInterrupt({ childSessionId, parentSessionId, mode }), + ), + }, + workspace: { + create: payload => this.record('workspace.create', payload, this.onWorkspaceCreate(payload)), + rename: payload => this.record('workspace.rename', payload, this.onWorkspaceRename(payload)), + delete: payload => this.record('workspace.delete', payload, this.onWorkspaceDelete(payload)), + insertBefore: payload => this.record( + 'workspace.insertBefore', + payload, + this.onWorkspaceInsertBefore(payload), + ), + insertSessionBefore: payload => this.record( + 'workspace.insertSessionBefore', + payload, + this.onWorkspaceInsertSessionBefore(payload), + ), + archiveSession: payload => this.record( + 'workspace.archiveSession', + payload, + this.onWorkspaceArchiveSession(payload), + ), + follow: signal => this.openWorkspace(signal), + }, + } + } + + /** Push one live Session event to every follower of that Session. */ + async pushFollow( + sessionId: SessionId, + frame: Extract, + ): Promise { + await Promise.all([...(this.followConns.get(sessionId) ?? [])].map(conn => new Promise((resolve) => { + conn.feed({ kind: 'frame', value: frame, delivered: resolve }) + }))) + } + + /** Push one Host-wide control update. */ + pushControl(frame: Exclude): void { + for (const conn of [...this.controlConns]) conn.feed({ kind: 'frame', value: frame }) + } + + /** Push one Workspace projection increment. */ + pushWorkspace(frame: Exclude): void { + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'frame', value: frame }) + } + + /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ + endStreams(): void { + for (const conns of this.followConns.values()) { + for (const conn of [...conns]) conn.feed({ kind: 'end' }) + } + for (const conn of [...this.controlConns]) conn.feed({ kind: 'end' }) + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'end' }) + } + + failStreams(error: unknown): void { + for (const conns of this.followConns.values()) { + for (const conn of [...conns]) conn.feed({ kind: 'fail', error }) + } + for (const conn of [...this.controlConns]) conn.feed({ kind: 'fail', error }) + for (const conn of [...this.workspaceConns]) conn.feed({ kind: 'fail', error }) + } + + callsOf(method: string): unknown[] { + return this.calls.filter(c => c.method === method).map(c => c.payload) + } + + /** Number of currently attached journal generations for one Session. */ + activeFollows(sessionId: SessionId): number { + return this.followConns.get(sessionId)?.length ?? 0 + } + + private record(method: string, payload: unknown, response: Promise): Promise { + this.calls.push({ method, payload }) + return response + } + + private async remoteResult( + method: string, + payload: unknown, + response: Promise>, + ): Promise> { + return (await this.record(method, payload, response)).result + } + + private page(request: SessionPageRequest): Promise> { + return this.fetchPage(request) + } + + private async fetchPage( + request: SessionPageRequest, + response?: Promise>, + ): Promise> { + const sessionId = addressSessionId(request.address) + const payload = request.address.kind === 'session' + ? { + sessionId, + throughSeq: request.throughSeq, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + } + : { + parentSessionId: request.address.parentSessionId, + childSessionId: request.address.childSessionId, + mode: request.address.mode, + throughSeq: request.throughSeq, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + } + const method = request.address.kind === 'session' ? 'session.history' : 'subagent.history' + const result = await this.remoteResult(method, payload, response ?? this.onHistory({ + sessionId, + throughSeq: request.throughSeq, + ...request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }, + ...request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }, + })) + if (!result.ok) return result + return { + ok: true, + value: { + ...result.value, + records: result.value.records + .filter(record => historyRecordLastSeq(record) <= request.throughSeq), + }, + } + } + + private async *openFollow( + request: SessionFollowRequest, + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const sessionId = addressSessionId(request.address) + this.followStarts.push(sessionId) + this.calls.push({ method: 'session.follow', payload: request }) + const conns = this.followConns.get(sessionId) ?? [] + if (!this.followConns.has(sessionId)) this.followConns.set(sessionId, conns) + const stream = this.openValueStream(conns, signal) + try { + const response = await this.onHistory({ + sessionId, + maxMessages: request.maxMessages ?? 50, + }) + if (!response.result.ok) { + throw new RemoteStreamError( + response.result.error.code, + response.result.error.message, + response.result.error.details, + ) + } + const page = response.result.value + const tail = page.records.at(-1) + const cursor = this.followCursor ?? (tail === undefined ? -1 : historyRecordLastSeq(tail)) + yield { + type: 'snapshot', + header: { + version: 0, + id: sessionId, + createdAt: 0, + ...(request.address.kind === 'subagent' + ? { origin: 'subagent' as const, parentSession: request.address.parentSessionId } + : {}), + }, + cursor, + records: page.records.filter(record => historyRecordLastSeq(record) <= cursor), + hasMore: page.hasMore, + projections: page.projections ?? { asOfSeq: cursor, values: {} }, + } + yield* stream.values + } finally { + stream.dispose() + } + } + + private async *openControl( + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const stream = this.openValueStream(this.controlConns, signal) + try { + yield { type: 'baseline', value: this.controlBaseline } + yield* stream.values + } finally { + stream.dispose() + } + } + + private async *openWorkspace( + signal: AbortSignal = new AbortController().signal, + ): AsyncGenerator { + const stream = this.openValueStream(this.workspaceConns, signal) + try { + yield { type: 'baseline', value: this.workspaceBaseline } + yield* stream.values + } finally { + stream.dispose() + } + } + + private openValueStream( + registry: ValueStreamConn[], + signal: AbortSignal, + ): OpenValueStream { + const inbox: ValueStreamItem[] = [] + let wake: (() => void) | null = null + let inFlightDelivered: (() => void) | undefined + let disposed = false + const conn: ValueStreamConn = { + feed: (item) => { + inbox.push(item) + wake?.() + }, + } + registry.push(conn) + const dispose = (): void => { + if (disposed) return + disposed = true + inFlightDelivered?.() + for (const item of inbox) { + if (item.kind === 'frame') item.delivered?.() + } + const index = registry.indexOf(conn) + if (index >= 0) registry.splice(index, 1) + wake?.() + } + const values = (async function* (): AsyncGenerator { + try { + while (!signal.aborted && !disposed) { + while (inbox.length > 0) { + const item = inbox.shift() as ValueStreamItem + if (item.kind === 'end') return + if (item.kind === 'fail') throw item.error + inFlightDelivered = item.delivered + yield item.value + inFlightDelivered?.() + inFlightDelivered = undefined + } + await new Promise((resolve) => { + wake = resolve + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + wake = null + } + } finally { + dispose() + } + })() + return { values, dispose } + } + +} diff --git a/packages/api/session-controller/tests/history-records.client.spec.ts b/packages/api/session-controller/tests/history-records.client.spec.ts new file mode 100644 index 0000000000..e8adbfcb1d --- /dev/null +++ b/packages/api/session-controller/tests/history-records.client.spec.ts @@ -0,0 +1,78 @@ +/** Packed history records become one event-shaped Client value per wire record. */ + +import { describe, expect, it } from 'vitest' +import { ToolCallId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionHistoryRecord } from '../src/types.ts' +import { + historyEntries, + historyRecordFirstSeq, + historyRecordLastSeq, +} from '../src/client/sessions/history-records.ts' + +describe('Session history record projection', () => { + it('retains an ordinary event and its point cursor', () => { + const ordinary: SessionHistoryRecord = { + type: 'event', + event: { type: 'turn/start', seq: 7, time: 1, data: { turn: 1 } }, + } + + const records = [ordinary] + const [entry] = historyEntries(records) + + expect(historyEntries(records)).toBe(records) + expect(entry).toBe(ordinary) + expect(historyRecordFirstSeq(ordinary)).toBe(7) + expect(entry?.event.time).toBe(1) + expect(historyRecordLastSeq(ordinary)).toBe(7) + }) + + it('retains one packed text row without copying or reshaping it', () => { + const packed: SessionHistoryRecord = { + type: 'chunks', + event: { + type: 'chunkrow/text-chunks', + seq: 11, + time: 20, + data: { turn: 1, step: 2, index: 0, dt: [1, 2, 3], texts: ['a', 'b', 'c', 'd'] }, + }, + } + + const [entry] = historyEntries([packed]) + if (entry?.type !== 'chunks') throw new Error('expected packed history entry') + const { event } = entry + + expect(entry).toBe(packed) + expect(event).toBe(packed.event) + expect(historyRecordFirstSeq(packed)).toBe(11) + expect(event.time).toBe(20) + expect(historyRecordLastSeq(packed)).toBe(14) + }) + + it('preserves a packed tool-call row and optional-name absence', () => { + const packed: SessionHistoryRecord = { + type: 'chunks', + event: { + type: 'chunkrow/tool-call-chunks', + seq: 20, + time: 200, + data: { + turn: 2, + step: 4, + index: 1, + id: ToolCallId('call-1'), + dt: [2, 3], + args: ['', '{"x":', '1}'], + }, + }, + } + + const [entry] = historyEntries([packed]) + if (entry?.type !== 'chunks') throw new Error('expected packed history entry') + const { event } = entry + + if (event.type !== 'chunkrow/tool-call-chunks') throw new Error('expected packed history event') + expect(event).toBe(packed.event) + expect(Object.hasOwn(event.data, 'name')).toBe(false) + expect(historyRecordLastSeq(packed)).toBe(22) + }) +}) diff --git a/packages/client/runtime/tests/lineage.client.spec.ts b/packages/api/session-controller/tests/lineage.client.spec.ts similarity index 94% rename from packages/client/runtime/tests/lineage.client.spec.ts rename to packages/api/session-controller/tests/lineage.client.spec.ts index 01ecacd1e2..b15b89e61b 100644 --- a/packages/client/runtime/tests/lineage.client.spec.ts +++ b/packages/api/session-controller/tests/lineage.client.spec.ts @@ -12,7 +12,7 @@ const s = (id: string, updatedAt: number, parent?: string): SessionSummary => ({ ...(parent !== undefined ? { parentSessionId: parent as SessionId } : {}), }) -describe('flattenLineage', () => { +describe('Session lineage flattening', () => { it('keeps established root and sibling order while expanding children DFS with depth', () => { const out = flattenLineage([ s('old-root', 10), @@ -54,7 +54,7 @@ describe('flattenLineage', () => { }) it('projects the completion-reminder set into rows (absent = false)', () => { - const out = flattenLineage([s('a', 10), s('b', 20)], undefined, new Set(['b' as SessionId])) + const out = flattenLineage([s('a', 10), s('b', 20)], new Set(['b' as SessionId])) expect(out.find(e => e.sessionId === 'a')?.completed).toBe(false) expect(out.find(e => e.sessionId === 'b')?.completed).toBe(true) expect(flattenLineage([s('a', 10)])[0]?.completed).toBe(false) diff --git a/packages/api/session-controller/tests/manager.client.spec.ts b/packages/api/session-controller/tests/manager.client.spec.ts new file mode 100644 index 0000000000..8b311de746 --- /dev/null +++ b/packages/api/session-controller/tests/manager.client.spec.ts @@ -0,0 +1,992 @@ +/** + * SessionManager orchestration: lazy resident instances, list lifecycle, host + * frame routing, and control baselines for uninstantiated sessions. + */ + +import { describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import type {} from '@deepseek-ai/dsh-session-title/client' +import { SessionManager } from '../src/client/sessions/manager.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr, remoteOk } from './fake-api.client.ts' +import { entries, plainTurn } from './event-script.client.ts' + +const S1 = 'fk-m1' as SessionId +const S2 = 'fk-m2' as SessionId + +type SummaryOver = Partial<{ + updatedAt: number + running: boolean + blank: boolean + cwd: string + parentSessionId: SessionId + origin: 'subagent' +}> + +function summary(sessionId: SessionId, over: SummaryOver = {}) { + return { sessionId, updatedAt: 100, running: false, blank: false, ...over } +} + +function makeManager(): SessionManager { + const api = new FakeApiClient() + return new SessionManager(fakeRemote(api)) +} + +describe('SessionManager instances', () => { + it('lazily builds one resident instance per id and syncs the running bit from the list', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + const session = manager.get(S1) + expect(manager.get(S1)).toBe(session) // resident: same instance forever + expect(session.getSnapshot().running).toBe(true) // list preceded instantiation + }) + +}) + +describe('list lifecycle', () => { + it('single-flights refreshList and preserves the Host baseline order', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(fakeRemote(api)) + const first = manager.refreshList() + const second = manager.refreshList() + expect(manager.getListSnapshot().state).toBe('loading') + gate.resolve(ok({ items: [summary(S2, { updatedAt: 200 }), summary(S1)] as never[] })) + await Promise.all([first, second]) + expect(api.callsOf('session.list')).toHaveLength(1) + const snapshot = manager.getListSnapshot() + expect(snapshot.state).toBe('idle') + expect(snapshot.items.map(i => i.sessionId)).toEqual([S2, S1]) + }) + + it('replays incremental frames over hydration and never batch-reorders established ids', async () => { + const api = new FakeApiClient() + const first = deferred>>() + api.onList = () => first.promise + const manager = new SessionManager(fakeRemote(api)) + const hydration = manager.refreshList() + manager.handleSessionAdded(summary(S2, { blank: true })) + first.resolve(ok({ items: [summary(S1)] as never[] })) + await hydration + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) + + api.onList = () => Promise.resolve(ok({ + items: [summary(S1, { updatedAt: 900 }), summary(S2, { updatedAt: 800 })] as never[], + })) + await manager.refreshList() + expect(manager.getListSnapshot().items.map(item => item.sessionId)).toEqual([S2, S1]) + }) + + it('advances list activity from the filtered Host notification', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + + manager.handleSessionActivity(S1, 500) + expect(manager.getListSnapshot().items[0]?.updatedAt).toBe(500) + }) + + it('keeps the error in the list snapshot on failure', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(err({ code: 'internal', message: 'boom', details: {} })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal' } }) + // A failed pull does not step the arrival phase: still pending. + expect(manager.getListSnapshot().phase).toBe('pending') + }) + + it('phase steps pending → ready on the first successful pull and never returns', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + expect(manager.getListSnapshot().phase).toBe('pending') + await manager.refreshList() + expect(manager.getListSnapshot().phase).toBe('ready') + // Sticky across later failures: the pull-activity axis reports the error, + // the arrival phase holds. + api.onList = () => Promise.resolve(err({ code: 'internal', message: 'down', details: {} })) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', phase: 'ready' }) + // And across an empty re-pull (empty-with-ready = truly no sessions). + api.onList = () => Promise.resolve(ok({ items: [] as never[] })) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'idle', phase: 'ready' }) + expect(manager.getListSnapshot().items).toEqual([]) + }) + + it('merges create into the list immediately without waiting for a refresh', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.resolve(ok({ sessionId: S2 })) + const manager = new SessionManager(fakeRemote(api)) + const result = await manager.create() + expect(result).toMatchObject({ ok: true, value: { sessionId: S2 } }) + expect(manager.getListSnapshot().items.map(i => i.sessionId)).toEqual([S2]) + }) + + it('retains title projections before list arrival, keeps last-wins by seq, and clears them on removal', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + const titleFrame = (title: string, seq: number) => { + manager.handleControlFrame({ type: 'projection', sessionId: S1, key: 'title', value: title, seq }) + } + titleFrame('Newest', 4) + titleFrame('Stale', 3) + titleFrame('Equal', 4) + api.onList = () => Promise.resolve(ok({ + items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[], + })) + await manager.refreshList() + + const titled = manager.getListSnapshot() + expect(titled.items.map(item => item.sessionId)).toEqual([S1, S2]) + expect(titled.items[0]?.title).toBe('Newest') + expect(titled.items[1]?.title).toBeUndefined() + + manager.handleSessionRemoved(S1) + manager.handleSessionAdded(summary(S1, { blank: true })) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S1)?.title).toBeUndefined() + }) + + it('seeds cold titles from the list rows\' projections block under higher-seq-wins', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + // A push frame landed before the list (S2's title is newer than the block's cut). + manager.handleControlFrame({ + type: 'projection', sessionId: S2, key: 'title', value: 'Pushed', seq: 9, + }) + api.onList = () => Promise.resolve(ok({ + items: [ + { ...summary(S1), projections: { asOfSeq: 4, values: { title: 'Cold cached' } } }, + { ...summary(S2, { updatedAt: 200 }), projections: { asOfSeq: 5, values: { title: 'List stale' } } }, + ] as never[], + })) + await manager.refreshList() + const items = manager.getListSnapshot().items + // Cold row: title surfaces straight from the list block — no open, no history. + expect(items.find(item => item.sessionId === S1)?.title).toBe('Cold cached') + // The stale list block (seq 5) cannot overwrite the newer push frame (seq 9). + expect(items.find(item => item.sessionId === S2)?.title).toBe('Pushed') + }) + + it('drops a projection row beyond the subscription baseline before accepting its durable replay', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1)] as never[] })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + const frame = (payload: SessionControlFrame) => { manager.handleControlFrame(payload) } + frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Unflushed', seq: 4 }) + + // The durable baseline says the host only knows up to seq 2: the phantom + // row rode lost state and must drop, or last-wins pins it forever. + frame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [S1]: { asOfSeq: 2, values: {} } }, + }, + }) + expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() + + frame({ type: 'projection', sessionId: S1, key: 'title', value: 'Durable', seq: 2 }) + expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') + + // A baseline at or past the row's seq keeps it (nothing phantom to drop). + frame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [S1]: { asOfSeq: 2, values: { title: 'Durable' } } }, + }, + }) + expect(manager.getListSnapshot().items[0]?.title).toBe('Durable') + }) +}) + +describe('search', () => { + it('returns bounded Host results and forwards the caller signal', async () => { + const api = new FakeApiClient() + api.onSearch = () => Promise.resolve(ok({ + items: [{ sessionId: S1, snippet: 'matching excerpt' }], + hasMore: true, + })) + const manager = new SessionManager(fakeRemote(api)) + const signal = new AbortController().signal + + await expect(manager.search('exact phrase', signal)).resolves.toEqual({ + ok: true, + value: { + items: [{ sessionId: S1, snippet: 'matching excerpt' }], + hasMore: true, + }, + }) + expect(api.callsOf('session.search')).toEqual([{ query: 'exact phrase' }]) + expect(api.lastSearchSignal).toBe(signal) + }) + + it('preserves business errors and folds transport failures', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + api.onSearch = () => Promise.resolve(err({ + code: 'internal', + message: 'index unavailable', + details: {}, + })) + const signal = new AbortController().signal + await expect(manager.search('first', signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'index unavailable' }, + }) + + api.onSearch = () => Promise.reject(new Error('wire down')) + await expect(manager.search('second', signal)).resolves.toMatchObject({ + ok: false, + error: { code: 'internal', message: 'wire down' }, + }) + }) +}) + +describe('Host Remote event routing', () => { + it('adds/removes/flips sessions and keeps removed instances resident', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionAdded(summary(S1, { blank: true })) // dup: ignored + expect(manager.getListSnapshot().items).toHaveLength(1) + + const session = manager.get(S1) + manager.handleSessionStatus(S1, true) + expect(session.getSnapshot().running).toBe(true) + expect(manager.getListSnapshot().items[0]?.running).toBe(true) + + manager.handleSessionError(S1, '炸了') + expect(session.getSnapshot().lastAgentError).toBe('炸了') + + manager.handleSessionRemoved(S1) + expect(manager.getListSnapshot().items).toHaveLength(0) + expect(session.getSnapshot().removed).toBe(true) + expect(manager.get(S1)).toBe(session) // resident-instance rule survives removal + }) +}) + +describe('subagent catalogs', () => { + it('keeps a catalog-discovered child address across ordinary selection and status frames', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [ + summary(S1), + summary(S2, { parentSessionId: S1, origin: 'subagent' }), + ] as never[] })) + api.onSubagentList = () => Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + await manager.refreshSubagents(S1) + manager.selectSubagent({ parentSessionId: S1, childSessionId: S2, mode: 'continuable' }) + + expect(manager.getListSnapshot().currentAddress).toEqual({ + parentSessionId: S1, childSessionId: S2, mode: 'continuable', + }) + expect(manager.get(S2).getSnapshot().subagent).toEqual({ + address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, + parentAvailable: true, + }) + // Clicking the same child through an ordinary list-selection path must not + // erase the catalog-derived address and fall back to session.* transport. + manager.select(S2) + expect(manager.getListSnapshot().currentAddress).toEqual({ + parentSessionId: S1, childSessionId: S2, mode: 'continuable', + }) + expect(manager.get(S2).getSnapshot().subagent).toEqual({ + address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, + parentAvailable: true, + }) + await manager.get(S2).open() + await manager.get(S2).prompt([{ type: 'text', text: 'continue' }], 'queue') + expect(api.callsOf('session.follow')).toEqual([ + { + address: { + kind: 'subagent', parentSessionId: S1, childSessionId: S2, mode: 'continuable', + }, + maxMessages: 50, + }, + ]) + expect(api.callsOf('subagent.history')).toEqual([]) + expect(api.callsOf('subagents.prompt')).toEqual([ + { + requestId: expect.any(String) as unknown as string, + parentSessionId: S1, childSessionId: S2, + mode: 'continuable', + content: [{ type: 'text', text: 'continue' }], + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ]) + expect(api.callsOf('session.history')).toEqual([]) + expect(api.callsOf('session.prompt')).toEqual([]) + const listCalls = api.callsOf('subagents.list').length + manager.handleSessionStatus(S2, false) + expect(manager.getListSnapshot().subagentsByParent[S1]?.entries[0]).toMatchObject({ + kind: 'child', id: S2, activity: 'inactive', + }) + expect(api.callsOf('subagents.list')).toHaveLength(listCalls) + + manager.handleSessionRemoved(S2) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toMatchObject({ + origin: 'subagent', parentSessionId: S1, running: false, + }) + expect(manager.get(S2).getSnapshot()).toMatchObject({ + removed: false, + subagent: { + address: { parentSessionId: S1, childSessionId: S2, mode: 'continuable' }, + }, + }) + }) + + it('refetches debounced membership only while the parent catalog is open', async () => { + vi.useFakeTimers() + try { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshSubagents(S1) + manager.setSubagentCatalogOpen(S1, true) + await Promise.resolve() + const baseline = api.callsOf('subagents.list').length + manager.handleSessionAdded(summary(S2, { parentSessionId: S1 })) + manager.handleSessionAdded(summary('fk-m3' as SessionId, { parentSessionId: S1 })) + await vi.advanceTimersByTimeAsync(50) + expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1) + + manager.setSubagentCatalogOpen(S1, false) + manager.handleSessionAdded(summary('fk-m4' as SessionId, { parentSessionId: S1 })) + await vi.advanceTimersByTimeAsync(50) + expect(api.callsOf('subagents.list')).toHaveLength(baseline + 1) + } finally { + vi.useRealTimers() + } + }) + + it('marks a loaded parent row expandable only for a direct subagent publication', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + api.onSubagentList = () => Promise.resolve(remoteOk({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'parent', + activity: 'inactive', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'ordinary parent', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshSubagents(root) + + manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { + parentSessionId: S1, origin: 'subagent', + })) + manager.handleSessionAdded(summary('fk-fork' as SessionId, { parentSessionId: S2 })) + + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, hasChildren: true }, + { kind: 'child', id: S2, hasChildren: false }, + ]) + }) + + it('preserves a live expandability hint across only the older in-flight catalog response', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const response = deferred>>() + api.onSubagentList = () => response.promise + const manager = new SessionManager(fakeRemote(api)) + const refresh = manager.refreshSubagents(root) + + manager.handleSessionAdded(summary('fk-grandchild' as SessionId, { + parentSessionId: S1, origin: 'subagent', + })) + response.resolve(remoteOk({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'parent', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await refresh + + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, hasChildren: true }, + ]) + + api.onSubagentList = () => Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'parent', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await manager.refreshSubagents(root) + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, hasChildren: false }, + ]) + }) + + it('replays status frames over an older in-flight catalog response', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const response = deferred>>() + api.onSubagentList = () => response.promise + const manager = new SessionManager(fakeRemote(api)) + const refresh = manager.refreshSubagents(root) + + manager.handleSessionStatus(S1, false) + manager.handleSessionStatus(S2, true) + response.resolve(remoteOk({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'stopped', + activity: 'running', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'started', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + await refresh + + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, activity: 'inactive' }, + { kind: 'child', id: S2, activity: 'running' }, + ]) + }) + + it('marks a detached catalog child inactive without requiring a selected address', async () => { + const api = new FakeApiClient() + api.onSubagentList = () => Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'running', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshSubagents(S1) + + manager.handleSessionRemoved(S2) + + expect(manager.getListSnapshot().subagentsByParent[S1]?.entries).toMatchObject([ + { kind: 'child', id: S2, activity: 'inactive' }, + ]) + }) + + it('coalesces overlapping catalog reads without scheduling a trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(fakeRemote(api)) + + const refresh = manager.refreshSubagents(root) + expect(manager.refreshSubagents(root)).toBe(refresh) + api.onSubagentList = () => Promise.resolve(remoteOk({ entries: [], parentAvailable: true })) + first.resolve(remoteOk({ entries: [], parentAvailable: true })) + await refresh + + expect(api.callsOf('subagents.list')).toHaveLength(1) + }) + + it('runs one trailing catalog refresh for a membership change coalesced into an in-flight pull', async () => { + vi.useFakeTimers() + try { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const first = deferred>>() + const second = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(fakeRemote(api), root) + const refresh = manager.refreshSubagents(root) + manager.setSubagentCatalogOpen(root, true) + + // A membership frame arrives while the pull is in flight; the debounced + // refresh it schedules fires 50ms later and is coalesced into the pull — + // which was requested before the new child existed. The stale mark must + // queue one trailing pull carrying the change. + manager.handleSessionAdded(summary(S2, { parentSessionId: root })) + await vi.advanceTimersByTimeAsync(50) + api.onSubagentList = () => second.promise + first.resolve(remoteOk({ + entries: [{ + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + await refresh + // The trailing pull is already in flight (kicked synchronously in finally). + second.resolve(remoteOk({ + entries: [ + { + kind: 'child', id: S1, mode: 'continuable', label: 'older', + activity: 'inactive', hasChildren: false, + }, + { + kind: 'child', id: S2, mode: 'continuable', label: 'new child', + activity: 'inactive', hasChildren: false, + }, + ] as never[], + parentAvailable: true, + })) + await second.promise + // The Remote face resolves one microtask after the response settles. + await vi.advanceTimersByTimeAsync(0) + + expect(api.callsOf('subagents.list')).toHaveLength(2) + expect(manager.getListSnapshot().subagentsByParent[root]?.entries).toMatchObject([ + { kind: 'child', id: S1, label: 'older' }, + { kind: 'child', id: S2, label: 'new child' }, + ]) + } finally { + vi.useRealTimers() + } + }) + + it('keeps removal invalidation across a stale success and failed trailing pull', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + const child = () => ({ + kind: 'child' as const, id: S2, mode: 'continuable' as const, label: 'worker', + activity: 'inactive' as const, hasChildren: false, + }) + const first = deferred>>() + api.onSubagentList = () => first.promise + const manager = new SessionManager(fakeRemote(api)) + const refresh = manager.refreshSubagents(root) + first.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) + await refresh + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + + // The removal lands while a second pull is in flight: the invalidation + // must survive the pre-removal ok response, so one trailing pull runs. + const mid = deferred>>() + api.onSubagentList = () => mid.promise + const midRefresh = manager.refreshSubagents(root) + manager.handleSessionRemoved(root) + const trailing = deferred>>() + api.onSubagentList = () => trailing.promise + mid.resolve(remoteOk({ entries: [child()] as never[], parentAvailable: true })) + await midRefresh + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + + trailing.resolve(remoteErr({ code: 'internal', message: 'trailing pull failed', details: {} })) + await vi.waitFor(() => { + expect(manager.getListSnapshot().subagentsByParent[root]).toMatchObject({ + state: 'error', + parentAvailable: false, + }) + }) + + const rootCalls = api.callsOf('subagents.list').filter(call => call === root) + expect(rootCalls).toHaveLength(3) + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) + + it('invalidates catalog availability when the owning parent is removed', async () => { + const api = new FakeApiClient() + const root = 'fk-root' as SessionId + api.onSubagentList = () => Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: S2, mode: 'continuable', label: 'worker', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: true, + })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshSubagents(root) + manager.selectSubagent({ parentSessionId: root, childSessionId: S2, mode: 'continuable' }) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: true }) + + manager.handleSessionRemoved(root) + + expect(manager.getListSnapshot().subagentsByParent[root]?.parentAvailable).toBe(false) + expect(manager.get(S2).getSnapshot().subagent).toMatchObject({ parentAvailable: false }) + }) +}) + +describe('remaining branches', () => { + it('refreshList folds a transport throw into the error state', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.reject(new Error('list wire down')) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + expect(manager.getListSnapshot()).toMatchObject({ state: 'error', error: { code: 'internal', message: 'list wire down' } }) + }) + + it('refreshList pushes running bits down to already-instantiated sessions', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + const session = manager.get(S1) + api.onList = () => Promise.resolve(ok({ items: [summary(S1, { running: true })] as never[] })) + await manager.refreshList() + expect(session.getSnapshot().running).toBe(true) + }) + + it('create passes cwd and a preallocated id, folds transport throws, and deduplicates the echo', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.resolve(ok({ sessionId: S1 })) + const manager = new SessionManager(fakeRemote(api)) + await manager.create({ cwd: '/tmp/w', sessionId: S1 }) + expect(api.callsOf('session.create')).toEqual([{ cwd: '/tmp/w', sessionId: S1 }]) + expect(manager.getListSnapshot().items[0]).toMatchObject({ sessionId: S1, cwd: '/tmp/w' }) + await manager.create({ cwd: '/tmp/w' }) // same id returned: no duplicate row + expect(manager.getListSnapshot().items).toHaveLength(1) + api.onCreate = () => Promise.reject(new Error('create wire down')) + expect(await manager.create()).toMatchObject({ ok: false, error: { code: 'internal' } }) + // Business error passes through untouched. + api.onCreate = () => Promise.resolve(err({ code: 'internal', message: 'no', details: {} })) + expect(await manager.create()).toMatchObject({ ok: false }) + }) + + it('publishes a real Ungrouped summary from workspace-attach-failed', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'published but unattached', + details: { sessionId: S1, workspaceId: 'w1' }, + } as never)) + const manager = new SessionManager(fakeRemote(api)) + const result = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) + expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ sessionId: S1 })]) + expect(manager.getListSnapshot().items[0]).not.toHaveProperty('cwd') + }) + + it('reconciles a fork child published before workspace attachment fails', async () => { + const api = new FakeApiClient() + api.onFork = () => Promise.resolve(err({ + code: 'workspace-attach-failed', + message: 'forked but unattached', + details: { sessionId: S2, workspaceId: 'w1' }, + } as never)) + const manager = new SessionManager(fakeRemote(api)) + const result = await manager.fork({ sessionId: S1 }) + expect(result).toMatchObject({ ok: false, error: { code: 'workspace-attach-failed' } }) + expect(manager.getListSnapshot().items).toEqual([expect.objectContaining({ + sessionId: S2, + parentSessionId: S1, + blank: false, + })]) + }) + + it('reconciles a preallocated id after an ordinary transport failure', async () => { + const api = new FakeApiClient() + api.onCreate = () => Promise.reject(new Error('response lost')) + const manager = new SessionManager(fakeRemote(api)) + const failed = await manager.create({ workspaceId: 'w1' as never, sessionId: S1 }) + expect(failed).toMatchObject({ ok: false, error: { message: 'response lost' } }) + expect(manager.getListSnapshot().items).toEqual([]) + + manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) + expect(manager.getListSnapshot().items).toEqual([ + expect.objectContaining({ sessionId: S1, cwd: '/w/one' }), + ]) + manager.handleSessionAdded(summary(S1, { blank: true, cwd: '/w/one' })) + expect(manager.getListSnapshot().items).toHaveLength(1) + }) + + it('subscribe notifies on list changes and stops after unsubscribe', async () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + let notified = 0 + const unsubscribe = manager.subscribe(() => { notified++ }) + await manager.refreshList() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(notified).toBeGreaterThan(0) + const seen = notified + unsubscribe() + manager.handleSessionAdded(summary(S1, { blank: true })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(notified).toBe(seen) + }) + + it('ignores Host status and error events for sessions without an instance', () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + manager.handleSessionStatus(S2, true) + manager.handleSessionError(S2, '无实例') + }) + + it('keeps list-entry identity for unchanged rows across an unrelated list change', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + const before = manager.getListSnapshot() + manager.handleSessionStatus(S2, true) + const after = manager.getListSnapshot() + expect(after.items).not.toBe(before.items) + const beforeS1 = before.items.find(e => e.sessionId === S1) + const afterS1 = after.items.find(e => e.sessionId === S1) + expect(afterS1).toBe(beforeS1) // untouched entry keeps identity (entryCache) + // Same-order same-entries snapshot reuses the items array. + manager.handleSessionError(S1, 'x') + expect(manager.getListSnapshot().items).toBe(after.items) + }) + + it('carries parentSessionId from the added event into the lineage row', () => { + const api = new FakeApiClient() + const manager = new SessionManager(fakeRemote(api)) + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleSessionAdded(summary(S2, { + blank: true, parentSessionId: S1, origin: 'subagent', + })) + const items = manager.getListSnapshot().items + expect(items.find(e => e.sessionId === S2)).toMatchObject({ + parentSessionId: S1, origin: 'subagent', depth: 1, + }) + }) +}) + +describe('connected generation', () => { + it('refreshes query baselines without rebuilding independently resumed Session sources', async () => { + const api = new FakeApiClient() + api.onHistory = () => Promise.resolve(ok({ + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, + })) + const manager = new SessionManager(fakeRemote(api)) + const openedSession = manager.get(S1) + await openedSession.open() + manager.get(S2) // instantiated but never opened + const historyCallsBefore = api.callsOf('session.history').length + manager.handleConnected() + await vi.waitFor(() => { + expect(api.callsOf('session.list').length).toBe(1) + }) + expect(api.callsOf('session.history')).toHaveLength(historyCallsBefore) + }) + + it('retains the durable parent address and refreshes its catalogs across reconnect', async () => { + const api = new FakeApiClient() + const address = { + parentSessionId: S1, childSessionId: S2, mode: 'continuable' as const, + } + const parent = deferred>>() + const child = deferred>>() + api.onSubagentList = payload => (payload === S1 ? parent.promise : child.promise) + const manager = new SessionManager(fakeRemote(api), S2, address) + + manager.handleConnected() + expect(manager.get(S2).getSnapshot().subagent).toEqual({ address }) + parent.resolve(remoteOk({ entries: [], parentAvailable: true })) + child.resolve(remoteOk({ entries: [], parentAvailable: true })) + + await vi.waitFor(() => { + expect(api.callsOf('session.list')).toHaveLength(1) + }) + await vi.waitFor(() => { + expect(api.callsOf('subagents.list')).toEqual([S1, S2]) + }) + expect(manager.get(S2).getSnapshot().subagent).toEqual({ + address, + parentAvailable: true, + }) + expect(manager.getListSnapshot().currentAddress).toEqual(address) + }) +}) + +describe('completed reminder', () => { + const status = (manager: SessionManager, sessionId: SessionId, running: boolean): void => { + manager.handleSessionStatus(sessionId, running) + } + const added = (manager: SessionManager, sessionId: SessionId): void => { + manager.handleSessionAdded(summary(sessionId)) + } + const entry = (manager: SessionManager, sessionId: SessionId) => + manager.getListSnapshot().items.find(item => item.sessionId === sessionId) + + it('arms on a running→idle flip of a non-selected session and clears on select', () => { + const manager = makeManager() + added(manager, S1) + added(manager, S2) + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + status(manager, S2, true) + status(manager, S2, false) + expect(entry(manager, S2)?.completed).toBe(true) + // Opening the session consumes the reminder. + manager.select(S2) + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('never arms for the session being watched and re-arms after a switch-away re-run', () => { + const manager = makeManager() + added(manager, S1) + added(manager, S2) + manager.select(S2) + status(manager, S2, true) + status(manager, S2, false) + expect(entry(manager, S2)?.completed).toBe(false) // watched to completion: no reminder + // Switch away; a fresh run completing again arms the reminder. + manager.select(S1) + status(manager, S2, true) + status(manager, S2, false) + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('a re-run disarms the reminder while running and re-arms on its completion', () => { + const manager = makeManager() + added(manager, S1) + added(manager, S2) + manager.select(S1) + status(manager, S2, true) + status(manager, S2, false) + expect(entry(manager, S2)?.completed).toBe(true) + // The user starts a new run without opening the session: running wins. + status(manager, S2, true) + expect(entry(manager, S2)?.completed).toBe(false) + status(manager, S2, false) + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('session-removed drops the reminder and a re-add starts clean', () => { + const manager = makeManager() + added(manager, S1) + added(manager, S2) + manager.select(S1) + status(manager, S2, true) + status(manager, S2, false) + expect(entry(manager, S2)?.completed).toBe(true) + manager.handleSessionRemoved(S2) + expect(manager.getListSnapshot().items.find(item => item.sessionId === S2)).toBeUndefined() + added(manager, S2) + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('a list refresh carrying the running→idle transition arms the reminder', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: false })] as never[] })) + await manager.refreshList() + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('never arms for sessions already idle at first observation', async () => { + const api = new FakeApiClient() + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + const manager = new SessionManager(fakeRemote(api)) + await manager.refreshList() + manager.select(S1) + expect(entry(manager, S2)?.completed).toBe(false) + api.onList = () => Promise.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 201 })] as never[] })) + await manager.refreshList() + expect(entry(manager, S2)?.completed).toBe(false) + }) + + it('arms a completion that happened during an in-flight first pull (baseline running, replayed idle)', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(fakeRemote(api)) + const refresh = manager.refreshList() + // The session finishes while the first pull is still in flight; the pull + // response recorded it as running at pull time. + status(manager, S2, false) + gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200, running: true })] as never[] })) + await refresh + expect(entry(manager, S2)?.completed).toBe(true) + }) + + it('arms when a session ran and completed entirely between in-flight mutations (baseline idle)', async () => { + const api = new FakeApiClient() + const gate = deferred>>() + api.onList = () => gate.promise + const manager = new SessionManager(fakeRemote(api)) + const refresh = manager.refreshList() + // The unknown session starts and finishes while the first pull is in + // flight; the pull-time baseline recorded it idle, so the running→idle + // edge lives entirely inside the replayed mutations. + status(manager, S2, true) + status(manager, S2, false) + gate.resolve(ok({ items: [summary(S1), summary(S2, { updatedAt: 200 })] as never[] })) + await refresh + expect(entry(manager, S2)?.completed).toBe(true) + }) +}) + +describe('background-job mirror', () => { + const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ + id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, + }) + const tasksFrame = ( + sessionId: SessionId, + jobs: unknown[], + ): Extract => ({ + type: 'jobs', sessionId, jobs: jobs as never, + }) + + it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleControlFrame(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) + const first = manager.getListSnapshot().jobsBySession + expect(first[S1]).toEqual([view()]) + expect(first[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + manager.handleControlFrame(tasksFrame(S1, [view({ status: 'completed' })])) + expect(manager.getListSnapshot().jobsBySession[S1]).toEqual([view({ status: 'completed' })]) + }) + + it('stores an emptied set as an absent key so absence and [] read alike', () => { + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + expect(S1 in manager.getListSnapshot().jobsBySession).toBe(true) + manager.handleControlFrame(tasksFrame(S1, [])) + expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) + }) + + it('clears the mirror when the next control baseline has no jobs', () => { + const manager = makeManager() + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + }) + expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) + }) + + it('drops the rows when the session is removed, whichever stream lands first', () => { + const manager = makeManager() + manager.handleSessionAdded(summary(S1, { blank: true })) + manager.handleControlFrame(tasksFrame(S1, [view()])) + manager.handleSessionRemoved(S1) + expect(S1 in manager.getListSnapshot().jobsBySession).toBe(false) + }) + + it('notifies list subscribers so an open header re-renders without a poll', async () => { + const manager = makeManager() + const seen = vi.fn() + manager.subscribe(seen) + manager.handleControlFrame(tasksFrame(S1, [view()])) + // The notifier batches on a microtask; the frame itself is already applied. + await Promise.resolve() + expect(seen).toHaveBeenCalled() + }) +}) diff --git a/packages/client/runtime/tests/notifier.client.spec.ts b/packages/api/session-controller/tests/notifier.client.spec.ts similarity index 99% rename from packages/client/runtime/tests/notifier.client.spec.ts rename to packages/api/session-controller/tests/notifier.client.spec.ts index f12d063400..dc5ca2f4ff 100644 --- a/packages/client/runtime/tests/notifier.client.spec.ts +++ b/packages/api/session-controller/tests/notifier.client.spec.ts @@ -12,7 +12,7 @@ afterEach(() => { vi.unstubAllGlobals() }) -describe('Notifier', () => { +describe('Session notifier', () => { it('collapses N markDirty calls into one flush, rebuilding before notifying', async () => { const order: string[] = [] const notifier = new Notifier(() => order.push('rebuild')) diff --git a/packages/client/runtime/tests/projection-store.client.spec.ts b/packages/api/session-controller/tests/projection-store.client.spec.ts similarity index 78% rename from packages/client/runtime/tests/projection-store.client.spec.ts rename to packages/api/session-controller/tests/projection-store.client.spec.ts index 5ef2c41194..64ee9d66f8 100644 --- a/packages/client/runtime/tests/projection-store.client.spec.ts +++ b/packages/api/session-controller/tests/projection-store.client.spec.ts @@ -4,7 +4,7 @@ * higher-seq-wins rule on both paths (a stale baseline cannot overwrite a * newer push frame; a replayed frame cannot regress), capability absence as * undefined, generation truncation, and the Session/manager wiring (tail-page - * seeding, session/projection frame routing pre- and post-instantiation, the + * seeding, control-stream projection routing pre- and post-instantiation, the * list rows' title projection). */ import { describe, expect, it } from 'vitest' @@ -25,7 +25,7 @@ declare module '@deepseek-ai/dsh-session-projection/types' { const SID = 'fk-s1' as SessionId -describe('ProjectionValueStore semantics', () => { +describe('Session projection value semantics', () => { it('reads undefined until a value lands (capability absence)', () => { const store = new ProjectionValueStore() expect(store.get('test/marks')).toBeUndefined() @@ -103,9 +103,9 @@ describe('ProjectionValueStore semantics', () => { describe('Session tail-page seeding', () => { it('seeds the store from a history response carrying a projections block', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ - events: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, + records: entries(plainTurn(0, 0, '问', '答')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['from-baseline'] } } }, } as never)) await session.open() @@ -114,9 +114,9 @@ describe('Session tail-page seeding', () => { it('a resync serving a stale block keeps the newer pushed value (seq rule end to end)', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) + const session = new Session(SID, fakeRemote(api)) api.onHistory = () => Promise.resolve(ok({ - events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false, projections: { asOfSeq: 5, values: { 'test/marks': { marks: ['baseline'] } } }, } as never)) await session.open() @@ -127,8 +127,8 @@ describe('Session tail-page seeding', () => { it('treats a blockless response as no reset: pushed values survive', async () => { const api = new FakeApiClient() - const session = new Session(SID, api, fakeRemote()) - api.onHistory = () => Promise.resolve(ok({ events: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) + const session = new Session(SID, fakeRemote(api)) + api.onHistory = () => Promise.resolve(ok({ records: entries(plainTurn(0, 0, 'a', 'b')) as never[], hasMore: false })) await session.open() session.projections.apply('test/marks', { marks: ['pushed'] }, 9) await session.resync() @@ -139,41 +139,41 @@ describe('Session tail-page seeding', () => { describe('manager frame routing', () => { const sid = (s: string): SessionId => s as SessionId - it('lands session/projection frames before instantiation and the Session adopts the same store', async () => { + it('lands projection frames before instantiation and the Session adopts the same store', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) - manager.handleMuxEnvelope({ - rpcId: 'p1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7 } as never, + const manager = new SessionManager(fakeRemote(api)) + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['early'] }, seq: 7, }) const session = manager.get(sid('s1')) expect(session.projections.get('test/marks')).toEqual({ marks: ['early'] }) // Frames after instantiation land in the same store. - manager.handleMuxEnvelope({ - rpcId: 'p2' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', value: { marks: ['later'] }, seq: 9, }) expect(session.projections.get('test/marks')).toEqual({ marks: ['later'] }) }) - it('projects the title key into list rows and truncates phantom rows on the subscribed baseline', async () => { + it('projects the title key into list rows and truncates phantom rows on the control baseline', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) await manager.refreshList() - manager.handleMuxEnvelope({ - rpcId: 't1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4 } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Projected title', seq: 4, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.title).toBe('Projected title') // The durable baseline says the host only knows up to seq 2: the row rode // lost state and must drop (the un-flushed title precedent). - manager.handleMuxEnvelope({ - rpcId: 'sub' as never, - payload: { type: 'session/subscribed', sessionId: sid('s1'), lastSeq: 2 } as never, + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: {}, jobs: {}, + projections: { [sid('s1')]: { asOfSeq: 2, values: {} } }, + }, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.title).toBeUndefined() @@ -181,7 +181,7 @@ describe('manager frame routing', () => { it('projects every retained value into list rows with stable snapshot identity', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false, @@ -196,12 +196,9 @@ describe('manager frame routing', () => { expect(baseline).toEqual({ 'test/marks': { marks: ['baseline'] } }) expect(manager.getListSnapshot().items[0]?.projectionValues).toBe(baseline) - manager.handleMuxEnvelope({ - rpcId: 'p2' as never, - payload: { - type: 'session/projection', sessionId: sid('s1'), key: 'test/marks', - value: { marks: ['live'] }, seq: 3, - } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'test/marks', + value: { marks: ['live'] }, seq: 3, }) await Promise.resolve() expect(manager.getListSnapshot().items[0]?.projectionValues) @@ -211,19 +208,15 @@ describe('manager frame routing', () => { it('drops the projection store with the removed session', async () => { const api = new FakeApiClient() - const manager = new SessionManager(api, fakeRemote()) + const manager = new SessionManager(fakeRemote(api)) api.onList = () => Promise.resolve(ok({ items: [{ sessionId: sid('s1'), updatedAt: 1, running: false, blank: false }], }) as never) await manager.refreshList() - manager.handleMuxEnvelope({ - rpcId: 't1' as never, - payload: { type: 'session/projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4 } as never, - }) - manager.handleHostEnvelope({ - rpcId: 'rm' as never, - payload: { type: 'host/session-removed', sessionId: sid('s1') } as never, + manager.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Doomed', seq: 4, }) + manager.handleSessionRemoved(sid('s1')) expect(manager.get(sid('s1')).projections.get('title')).toBeUndefined() }) }) diff --git a/packages/api/session-controller/tests/queue-store.client.spec.ts b/packages/api/session-controller/tests/queue-store.client.spec.ts new file mode 100644 index 0000000000..6fc045cf70 --- /dev/null +++ b/packages/api/session-controller/tests/queue-store.client.spec.ts @@ -0,0 +1,287 @@ +/** + * Queue snapshot semantics: authoritative replacement after every host-side + * change, reconnect re-baselining, pre-instantiation buffering, editable-text + * projection, and snapshot reference stability. + */ +import { describe, expect, it, vi } from 'vitest' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm/types' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { MessageId, RpcId, SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import type { SessionControlFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import { Session } from '../src/client/sessions/session.ts' +import { SessionManager } from '../src/client/sessions/manager.ts' +import { FakeApiClient, fakeRemote } from './fake-api.client.ts' + +const SID = 'fk-q1' as SessionId +const text = (value: string): ContentBlock[] => [{ type: 'text', text: value }] +const rid = (id: string): RpcId => id as RpcId +const iid = (id: string): MessageId => id as MessageId + +interface QueueFixture { + id: string + body: string + content?: ContentBlock[] + placement?: 'queued' | 'steering' + message?: UserMessage +} + +/** Build one authoritative queue snapshot. */ +function queueFrame(items: QueueFixture[]): Extract { + return { + type: 'queue', + sessionId: SID, + items: items.map(item => ({ + id: iid(item.id), + placement: item.placement ?? 'queued', + message: (item.message ?? createUserMessage({ + content: item.content ?? text(item.body), + source: { kind: 'user', rpcId: rid(`rpc-${item.id}`) } as never, + })) as never, + })), + } +} + +function makeSession(): Session { + return makeBench().session +} + +function makeBench(): { api: FakeApiClient; session: Session } { + const api = new FakeApiClient() + return { api, session: new Session(SID, fakeRemote(api)) } +} + +function makeManager(): SessionManager { + const api = new FakeApiClient() + return new SessionManager(fakeRemote(api)) +} + +describe('Session queue snapshot intake', () => { + it('projects stable ids, flat previews, and complete text', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([ + { id: 'q-1', body: '第一条 排队\n消息' }, + ])) + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-1', placement: 'queued', + content: [{ type: 'text', text: '第一条 排队\n消息' }], + preview: '第一条 排队 消息', text: '第一条 排队\n消息', + }, + ]) + }) + + it('marks mixed-content messages non-editable while retaining their preview', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([{ + id: 'q-image', + body: '', + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' } as never], + }])) + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-image', placement: 'queued', + content: [{ type: 'text', text: 'hi' }, { type: 'image', data: 'x' }], + preview: 'hi [image]', text: null, + }, + ]) + }) + + it('caps previews at 200 code points and preserves the full editable text', () => { + const session = makeSession() + const body = '长'.repeat(201) + session.handleControlFrame(queueFrame([{ id: 'q-cap', body }])) + const row = session.getSnapshot().queue[0] + expect(Array.from(row?.preview ?? '')).toHaveLength(201) + expect(row?.preview.endsWith('…')).toBe(true) + expect(row?.text).toBe(body) + }) + + it('replaces content, order, and membership from each authoritative frame', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([ + { id: 'q-1', body: 'one' }, + { id: 'q-2', body: 'two' }, + ])) + session.handleControlFrame(queueFrame([ + { id: 'q-2', body: 'two edited' }, + ])) + const queue = session.getSnapshot().queue + expect(typeof queue[0]?.messageId).toBe('string') + expect(queue).toMatchObject([ + { + id: 'q-2', placement: 'queued', + content: [{ type: 'text', text: 'two edited' }], + preview: 'two edited', text: 'two edited', + }, + ]) + session.handleControlFrame(queueFrame([])) + expect(session.getSnapshot().queue).toEqual([]) + }) + + it('keeps the queue array reference stable across unrelated snapshot swaps', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([{ id: 'q-stable', body: '稳定' }])) + const before = session.getSnapshot().queue + session.handleAgentError('unrelated') + expect(session.getSnapshot().queue).toBe(before) + }) + + it('retains steering placement and complete content in the same authoritative snapshot', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([ + { id: 'q-next', body: 'later' }, + { id: 's-now', body: 'interrupt now', placement: 'steering' }, + ])) + + expect(session.getSnapshot().queue.map(item => ({ + id: item.id, placement: item.placement, content: item.content, + }))).toEqual([ + { id: 'q-next', placement: 'queued', content: text('later') }, + { id: 's-now', placement: 'steering', content: text('interrupt now') }, + ]) + }) + + it('hands off exactly one current occurrence when live steering becomes durable', async () => { + const { api, session } = makeBench() + await session.open() + const message = createUserMessage({ + content: text('same message'), + source: { kind: 'user' }, + }) + session.handleControlFrame(queueFrame([ + { id: 's-first', body: '', placement: 'steering', message }, + { id: 's-second', body: '', placement: 'steering', message }, + ])) + const durable = { + seq: 0, + time: 1_700_000_000_000, + type: 'user/message', + surfaceOp: 'append', + data: message, + } as SessionEvent + + await api.pushFollow(SID, { type: 'event', event: durable as never }) + await vi.waitFor(() => { + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-second']) + }) + + session.handleControlFrame(queueFrame([ + { id: 's-later', body: '', placement: 'steering', message }, + ])) + await api.pushFollow(SID, { type: 'event', event: durable as never }) + await vi.waitFor(() => { + expect(session.getSnapshot().queue.map(item => item.id)).toEqual(['s-later']) + }) + }) + + it('hands off live steering when the agent claims it as a user message', async () => { + const { api, session } = makeBench() + await session.open() + const message = createUserMessage({ + content: text('claimed steering'), + source: { kind: 'user' }, + }) + session.handleControlFrame(queueFrame([ + { id: 's-claimed', body: '', placement: 'steering', message }, + ])) + + await api.pushFollow(SID, { + type: 'event', + event: { + seq: 0, + time: 1_700_000_000_000, + type: 'user/message', + surfaceOp: 'append', + data: message, + } as never, + }) + + await vi.waitFor(() => { + expect(session.getSnapshot().queue).toEqual([]) + }) + }) +}) + +describe('queue operation transport', () => { + it('addresses the session.updateQueue RPC without optimistic local mutation', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api)) + session.handleControlFrame(queueFrame([{ id: 'q-op', body: 'pending' }])) + const before = session.getSnapshot().queue + + await expect(session.updateQueue(iid('q-op'), { kind: 'edit', content: text('next') })) + .resolves.toEqual({ ok: true, value: { accepted: true } }) + await expect(session.updateQueue(iid('q-op'), { kind: 'steer' })) + .resolves.toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('session.updateQueue')).toEqual([ + { + sessionId: SID, + itemId: 'q-op', + action: { kind: 'edit', content: text('next') }, + }, + { + sessionId: SID, + itemId: 'q-op', + action: { kind: 'steer' }, + }, + ]) + expect(session.getSnapshot().queue).toBe(before) + }) +}) + +describe('queue reconnect semantics', () => { + it('a control baseline clears stale state before a fresh update lands', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧连接' }])) + session.replaceControl([]) + expect(session.getSnapshot().queue).toEqual([]) + session.handleControlFrame(queueFrame([{ id: 'q-new', body: '新基线' }])) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) + }) + + it('resync does not clear a baseline that raced ahead of the host connection signal', async () => { + const session = makeSession() + await session.open() + session.handleControlFrame(queueFrame([{ id: 'q-fresh', body: '新基线' }])) + await session.resync() + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-fresh']) + }) + + it('running-status changes never guess at queue retirement', () => { + const session = makeSession() + session.handleControlFrame(queueFrame([{ id: 'q-live', body: '保留' }])) + session.handleRunning(true) + session.handleRunning(false) + expect(session.getSnapshot().queue.map(row => row.id)).toEqual(['q-live']) + }) +}) + +describe('manager buffering of queue snapshots', () => { + it('replays only the latest snapshot for an uninstantiated session', () => { + const manager = makeManager() + manager.handleControlFrame(queueFrame([{ id: 'q-old', body: '旧' }])) + manager.handleControlFrame(queueFrame([{ id: 'q-new', body: '新' }])) + expect(manager.get(SID).getSnapshot().queue.map(row => row.id)).toEqual(['q-new']) + }) + + it('a control baseline replaces the prior queue', () => { + const manager = makeManager() + manager.handleControlFrame(queueFrame([{ id: 'q-g1', body: '第一代' }])) + const nextQueue = queueFrame([{ id: 'q-g2', body: '第二代' }]).items + manager.handleControlFrame({ + type: 'baseline', + value: { + queues: { [SID]: nextQueue }, + jobs: {}, + projections: {}, + }, + }) + const snapshot = manager.get(SID).getSnapshot() + expect(snapshot.queue.map(row => row.id)).toEqual(['q-g2']) + }) +}) diff --git a/packages/client/runtime/tests/scope.client.spec.ts b/packages/api/session-controller/tests/scope.client.spec.ts similarity index 97% rename from packages/client/runtime/tests/scope.client.spec.ts rename to packages/api/session-controller/tests/scope.client.spec.ts index 528c36131e..a0d7be3e7e 100644 --- a/packages/client/runtime/tests/scope.client.spec.ts +++ b/packages/api/session-controller/tests/scope.client.spec.ts @@ -9,7 +9,7 @@ import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' -import { createScope, scopeOf } from '../src/client/agents/scope.ts' +import { createScope, scopeOf } from '../src/client/scope.ts' const sid = (k: string): SessionId => k as SessionId diff --git a/packages/api/session-controller/tests/session-cold.host.spec.ts b/packages/api/session-controller/tests/session-cold.host.spec.ts new file mode 100644 index 0000000000..7156ac569d --- /dev/null +++ b/packages/api/session-controller/tests/session-cold.host.spec.ts @@ -0,0 +1,899 @@ +/** + * Cold-session and degenerate-composition paths of the Session Controller: + * metadata-only listing, Agent-free history reads, subagent ownership + * isolation, and prompt failure mapping. + */ + +import { describe, expect, it, vi } from 'vitest' +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' +import { TypertLookupFailure } from '@deepseek-ai/dsh-typert-protocol' +import TypertRegistry from '@deepseek-ai/dsh-typert-registry' +import { createUserMessage, MessageId } from '@deepseek-ai/dsh-llm' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' +import { + PersistenceCoordinator, + SessionPersistenceRevision, + type PersistenceBackend, + type StoredPrefix, +} from '@deepseek-ai/dsh-session-persistence' +import { ApiSessionList } from '../src/list.ts' +import { + createSessionTestRemote, + installSessionReadTestServices, + testSessionPersistence, +} from './test-remote.ts' + +const sid = (id: string): SessionId => id as SessionId + +function request

(payload: P): P { + return payload +} + +let nextRequestId = 1 +function promptRequest( + payload: Omit, +): SessionPromptRequest { + return { + ...payload, + requestId: `cold-${String(nextRequestId++)}` as SessionRequestId, + } +} + +function header(id: string, createdAt: number, extra: Partial = {}): SessionHeader { + return { version: 0, id: sid(id), createdAt, cwd: '/proj', ...extra } +} + +function providePersistence(ctx: Context, persistence: Record): () => void { + return ctx.provide('sessionPersistence', testSessionPersistence(ctx, persistence) as never) +} + +describe('sessions.list cold merge', () => { + it('fully observes only small possibly-blank artifacts and treats unavailable probes as visible', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const root = mkdtempSync(join(tmpdir(), 'dsh-cold-')) + const smallPath = join(root, 'small.log') + const largePath = join(root, 'large.log') + writeFileSync(smallPath, 'x'.repeat(1024)) + writeFileSync(largePath, 'x'.repeat(1025)) + const metas = [ + header('small-blank', 100), + header('small-conversation', 200), + header('large-unknown', 300), + header('cached-nonblank', 400), + header('locationless', 500, { parentSession: sid('session-parent'), origin: 'subagent' }), + header('vanished', 600), + header('read-failure', 700), + { version: 0, id: sid('missing-cwd'), createdAt: 800 }, + ] + const inspect = vi.fn(async (id: SessionId) => { + if (id === sid('small-blank')) { + return { + meta: metas[0]!, + events: [{ type: 'session/end-seed', seq: 0, time: 700, data: {} }] as SessionEvent[], + } + } + if (id === sid('small-conversation')) { + return { + meta: metas[1]!, + events: [ + { type: 'turn/start', seq: 0, time: 800, data: { turn: 1 } }, + { + type: 'user/message', seq: 1, time: 1200, + data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + ] as SessionEvent[], + } + } + if (id === sid('read-failure')) throw new Error('simulated read failure') + throw new Error(`unexpected cold read: ${id}`) + }) + providePersistence(ctx, { + list: () => Promise.resolve(metas), + locate: (meta: SessionHeader) => { + if (meta.id === sid('large-unknown')) return { kind: 'jsonl', path: largePath } + if (meta.id === sid('locationless')) return undefined + if (meta.id === sid('vanished')) return { kind: 'jsonl', path: join(root, 'vanished.log') } + return { kind: 'jsonl', path: smallPath } + }, + inspect, + }) + ctx.provide('sessionProjectionCache', { + cachedSnapshot: (meta: SessionHeader) => { + if (meta.id === sid('small-blank')) { + return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: null } } } + } + if (meta.id === sid('small-conversation')) { + return { asOfSeq: 0, values: { sessionListMetadata: { blank: true, lastPromptAt: 900 } } } + } + if (meta.id === sid('cached-nonblank')) { + return { asOfSeq: 1, values: { sessionListMetadata: { blank: false, lastPromptAt: 1000 } } } + } + return undefined + }, + hydratePrepared: (session: Session, _meta: SessionHeader, events: readonly SessionEvent[]) => + ctx.sessionProjections.hydrate(session, {}, events, 0), + } as never) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const response = await remote.list(request({})) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + const byId = Object.fromEntries(response.value.items.map(item => [item.sessionId, item])) + expect(byId['small-blank']).toMatchObject({ blank: true, updatedAt: 100, running: false }) + expect(byId['small-conversation']).toMatchObject({ blank: false, updatedAt: 1200 }) + expect(byId['large-unknown']).toMatchObject({ blank: false, updatedAt: 300 }) + expect(byId['cached-nonblank']).toMatchObject({ blank: false, updatedAt: 1000 }) + expect(byId['locationless']).toMatchObject({ + blank: false, + updatedAt: 500, + parentSessionId: 'session-parent', + origin: 'subagent', + }) + expect(byId['vanished']).toMatchObject({ blank: false, updatedAt: 600 }) + expect(byId['read-failure']).toMatchObject({ blank: false, updatedAt: 700 }) + expect(byId['missing-cwd']).toBeUndefined() + expect(inspect).toHaveBeenCalledTimes(3) + expect(inspect.mock.calls.map(([id]) => id)).toEqual(expect.arrayContaining([ + sid('small-blank'), + sid('small-conversation'), + sid('read-failure'), + ])) + }) + + it('can disable bounded cold observations without hiding cold Sessions', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('probe-disabled', 100) + const inspect = vi.fn() + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + locate: () => ({ kind: 'jsonl', path: '/not-read' }), + inspect, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + coldBlankProbeMaxBytes: 0, + }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('unreachable') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false, updatedAt: meta.createdAt }), + ]) + expect(inspect).not.toHaveBeenCalled() + }) + + it('prefers a live row attached during the query without folding its seed', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const meta = header('attached-during-list', 100) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + providePersistence(ctx, { + list: async () => { + started.resolve(undefined) + await release.promise + return [meta] + }, + }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const listing = remote.list(request({})) + await started.promise + const session = ctx.sessions.create(meta.id, { + seed: [ + { type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }, + { + type: 'user/message', seq: 1, time: 300, + data: createUserMessage({ content: [{ type: 'text', text: 'live' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + ], + meta: { + ...meta.cwd === undefined ? {} : { cwd: meta.cwd }, + createdAt: meta.createdAt, + }, + }) + ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) + release.resolve(undefined) + + const response = await listing + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ + sessionId: meta.id, + blank: false, + running: true, + updatedAt: 100, + }), + ]) + }) + + it('prefers a Session that attaches during its bounded cold observation', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const root = mkdtempSync(join(tmpdir(), 'dsh-cold-race-')) + const path = join(root, 'small.log') + writeFileSync(path, 'small') + const meta = header('attached-during-probe', 100) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + locate: () => ({ kind: 'jsonl', path }), + inspect: () => { + const session = ctx.sessions.create(meta.id, { + meta, + seed: [{ type: 'turn/start', seq: 0, time: 200, data: { turn: 1 } }], + }) + ctx.agents.register({ id: session.id, session, status: 'running', ctx } as Agent) + return Promise.resolve({ meta, events: [] }) + }, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + const response = await remote.list(request({})) + if (!response.ok) throw new Error('list failed') + expect(response.value.items).toEqual([ + expect.objectContaining({ sessionId: meta.id, running: true, blank: false }), + ]) + await ctx.fiber.dispose() + }) + + it('propagates a cold location failure instead of returning a partial list', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const meta = header('broken-cache', 100) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + locate: () => { throw new Error('location failed') }, + }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', + }) + + await expect(remote.list(request({}))).resolves.toMatchObject({ + ok: false, + error: { message: expect.stringContaining('location failed') as string }, + }) + await ctx.fiber.dispose() + }) + + it('supports an unsignalled probe whose observation has no projection registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-cold-unprojected-')) + const path = join(root, 'small.log') + writeFileSync(path, 'small') + const meta = header('unprojected-small', 100) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([meta]), + locate: () => ({ kind: 'jsonl', path }), + } as never) + vi.spyOn(ctx.sessionQuery, 'listSessions').mockResolvedValue([{ + header: meta, live: false, persisted: true, + }]) + vi.spyOn(ctx.sessionQuery, 'observeSession').mockResolvedValue({ + source: 'prepared', header: meta, events: [], cursor: -1, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + }) + const list = new ApiSessionList(ctx, 1024) + + await expect(list.list()).resolves.toEqual([ + expect.objectContaining({ sessionId: meta.id, blank: false }), + ]) + await ctx.fiber.dispose() + }) +}) + +describe('attached updatedAt tracks human prompts', () => { + it('ignores pickup and non-prompt work after the latest human message', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await new Promise(resolve => setTimeout(resolve, 0)) + + // Old work, resumed just now: the log tail would report the pickup. + const worked = 1_000_000 + const resumed = ctx.sessions.create(sid('resumed-untouched'), { + seed: [ + { type: 'turn/start', seq: 0, time: worked, data: { turn: 1 } }, + { + type: 'user/message', seq: 1, time: worked, + data: createUserMessage({ content: [{ type: 'text', text: 'worked' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: 2, time: worked + 1, data: { turn: 1, reason: { kind: 'completed' } } }, + ], + meta: { cwd: '/proj', createdAt: 500 }, + }) + ctx.agents.register({ id: resumed.id, session: resumed, status: 'idle', ctx } as Agent) + const boundary = resumed.events.at(-1) + expect(boundary?.type).toBe('session/end-seed') + expect(boundary?.time).toBeGreaterThan(worked) + + const listed = await remote.list(request({})) + if (!listed.ok) throw new Error('list failed') + const summary = listed.value.items.find(item => item.sessionId === 'resumed-untouched') + expect(summary?.updatedAt).toBe(500) + + // A lifecycle boundary is not a human update. + resumed.append('turn/start', { turn: 2 }) + const afterBoundary = await remote.list(request({})) + if (!afterBoundary.ok) throw new Error('list failed') + expect(afterBoundary.value.items.find(item => item.sessionId === 'resumed-untouched')?.updatedAt) + .toBe(worked) + + const prompt = resumed.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'new prompt' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const after = await remote.list(request({})) + if (!after.ok) throw new Error('list failed') + const moved = after.value.items.find(item => item.sessionId === 'resumed-untouched') + expect(moved?.updatedAt).toBe(prompt.time) + }) +}) + +describe('cold history recovery view', () => { + it('shows in-memory interruption repair without activating the session', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = sid('session-interrupted') + const meta = header(sessionId, 1000) + const stored: StoredPrefix = { + meta, + events: [{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }], + revision: SessionPersistenceRevision('history-recovery-test:1'), + } + const backend: PersistenceBackend = { + name: 'history-recovery-test', + loadStored: id => Promise.resolve(id === sessionId ? structuredClone(stored) : undefined), + readStoredRevision: id => Promise.resolve( + id === sessionId ? SessionPersistenceRevision('history-recovery-test:1') : undefined, + ), + appendBatch: () => Promise.resolve(), + commitRepair: () => Promise.resolve(), + list: () => Promise.resolve([structuredClone(meta)]), + } + const coordinator = new PersistenceCoordinator(ctx, backend) + providePersistence(ctx, { + list: (signal?: AbortSignal) => backend.list(signal), + inspect: (id: SessionId, signal?: AbortSignal) => coordinator.inspect(id, signal), + borrowSession: (id: SessionId, signal?: AbortSignal) => coordinator.borrowSession(id, signal), + locate: () => undefined, + }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const history = await remote.page({ + address: { kind: 'session', sessionId }, + throughSeq: 1, + beforeSeq: 2, + maxMessages: 10, + }) + if (!history.ok) throw new Error('history failed') + expect(history.value.records.map(record => record.event)).toMatchInlineSnapshot(` + [ + { + "data": { + "turn": 1, + }, + "seq": 0, + "time": 1, + "type": "turn/start", + }, + { + "data": { + "reason": { + "kind": "interrupted", + }, + "turn": 1, + }, + "seq": 1, + "time": 1, + "type": "turn/end", + }, + ] + `) + expect(ctx.sessions.get(sessionId)).toBeUndefined() + await ctx.fiber.dispose() + }) +}) + +describe('Remote Agent and Session lookup policy', () => { + it('deduplicates a cold resume across Agent and Session parameters', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = sid('session-remote-cold') + const meta = header(sessionId, 1000) + const inspect = vi.fn(() => Promise.resolve({ meta, events: [] as SessionEvent[] })) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + }) + const resumedSession = { id: sessionId, header: meta, events: [] } as unknown as import('@deepseek-ai/dsh-session').Session + const resumedAgent = { id: sessionId, session: resumedSession, status: 'idle', ctx } as Agent + const release = Promise.withResolvers() + const resume = vi.spyOn(ctx.agents, 'resume').mockImplementation(async () => { + await release.promise + return { agent: resumedAgent, dispose: () => Promise.resolve() } + }) + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + + const resolvedAgent = Promise.resolve(agentLookup.resolve(sessionId)) + const resolvedSession = Promise.resolve(sessionLookup.resolve(sessionId)) + await vi.waitFor(() => { expect(resume).toHaveBeenCalledOnce() }) + release.resolve(undefined) + + await expect(resolvedAgent).resolves.toBe(resumedAgent) + await expect(resolvedSession).resolves.toBe(resumedSession) + expect(inspect).toHaveBeenCalledOnce() + }) + + it('preserves the subagent ownership fence for cold and live Remote lookups', async () => { + const ctx = new Context() + await ctx.plugin(TypertRegistry) + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const coldId = sid('session-remote-cold-child') + const coldMeta = header(coldId, 1000, { + parentSession: sid('session-parent'), + origin: 'subagent', + }) + const inspect = vi.fn(() => Promise.resolve({ meta: coldMeta, events: [] as SessionEvent[] })) + providePersistence(ctx, { + list: () => Promise.resolve([coldMeta]), + inspect, + locate: () => undefined, + }) + const liveSession = ctx.sessions.create(sid('session-remote-live-child'), { + meta: { cwd: '/proj', parentSession: sid('session-parent'), origin: 'subagent' }, + }) + const liveAgent = { id: liveSession.id, session: liveSession, status: 'idle', ctx } as Agent + ctx.agents.register(liveAgent) + const resume = vi.spyOn(ctx.agents, 'resume') + const defaultAgentLookup = ctx.typert.lookups.get('agent') + const defaultSessionLookup = ctx.typert.lookups.get('session') + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + await vi.waitFor(() => { + expect(ctx.typert.lookups.get('agent')).not.toBe(defaultAgentLookup) + expect(ctx.typert.lookups.get('session')).not.toBe(defaultSessionLookup) + }) + const agentLookup = ctx.typert.lookups.get('agent') + const sessionLookup = ctx.typert.lookups.get('session') + if (agentLookup === undefined || sessionLookup === undefined) throw new Error('core lookup providers were not mounted') + const ownershipFailure = { + failure: { + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }, + } + + const coldFailure = Promise.resolve(agentLookup.resolve(coldId)) + const liveFailure = Promise.resolve(sessionLookup.resolve(liveSession.id)) + await expect(coldFailure).rejects.toBeInstanceOf(TypertLookupFailure) + await expect(coldFailure).rejects.toMatchObject(ownershipFailure) + await expect(liveFailure).rejects.toBeInstanceOf(TypertLookupFailure) + await expect(liveFailure).rejects.toMatchObject(ownershipFailure) + expect(resume).not.toHaveBeenCalled() + expect(inspect).toHaveBeenCalledOnce() + }) +}) + +describe('subagent ownership fence', () => { + it('reads a cold child without an Agent and rejects generic resume or adoption', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = sid('session-child') + const meta = header('session-child', 1000, { + parentSession: sid('session-parent'), + seedLength: 0, + origin: 'subagent', + }) + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { + type: 'subagent/descriptor', + seq: 2, + time: 3, + data: snapshotSubagentDescriptor({ + mode: 'continuable', + provider: 'spawn', + label: 'child', + }), + }, + { type: 'turn/end', seq: 3, time: 4, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + const inspect = vi.fn(() => Promise.resolve({ meta, events })) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect, + locate: () => undefined, + }) + const resume = vi.spyOn(ctx.agents, 'resume') + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + + const history = await new SessionHistoryController( + ctx, + (observation) => { observation[Symbol.dispose]() }, + ).page({ + address: { + kind: 'subagent', + parentSessionId: meta.parentSession as SessionId, + childSessionId: sessionId, + mode: 'continuable', + }, + throughSeq: 3, + }, new AbortController().signal) + expect(history.records.map(record => record.event.type)) + .toEqual(events.map(event => event.type)) + expect(ctx.agents.get(sessionId)).toBeUndefined() + + const prompt = await remote.prompt(promptRequest({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'follow up' }], + })) + expect(prompt.ok).toBe(false) + if (!prompt.ok) { + expect(prompt.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + + const create = await remote.create(request({ sessionId, cwd: '/proj' })) + expect(create.ok).toBe(false) + if (!create.ok) expect(create.error.code).toBe('agent-busy') + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.get(sessionId)).toBeUndefined() + expect(inspect).toHaveBeenCalledTimes(3) + }) + + it('no longer treats a descriptor-only cold child without origin as subagent-owned', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = sid('session-legacy-child') + const meta = header('session-legacy-child', 1000, { + parentSession: sid('session-parent'), + seedLength: 0, + }) + const events = [ + { + type: 'subagent/descriptor', + seq: 0, + time: 1, + data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'child' }, + }, + ] as SessionEvent[] + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events }), + locate: () => undefined, + }) + // Stores whose headers predate `origin` classify a child only through the + // descriptor event; the pre-release decision stops recognizing them, so + // the ownership fence lets generic resume reach the registry instead of + // answering `agent-busy`. + const resume = vi.spyOn(ctx.agents, 'resume') + .mockRejectedValue(new Error('registry unavailable in this bench')) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const prompt = await remote.prompt(promptRequest({ + sessionId, + mode: 'queue', + content: [{ type: 'text', text: 'follow up' }], + })) + expect(resume).toHaveBeenCalledTimes(1) + expect(prompt.ok).toBe(false) + if (!prompt.ok) expect(prompt.error.code).toBe('internal') + }) + + it('rejects origin-marked and runtime-owned live children from generic controls', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const parentSession = ctx.sessions.create(sid('session-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + + const originSession = ctx.sessions.create(sid('session-origin-child'), { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const cancel = vi.fn() + const updateInbox = vi.fn(() => 'applied' as const) + const originChild = { + id: originSession.id, + session: originSession, + status: 'idle', + ctx, + cancel, + updateInbox, + } as unknown as Agent + ctx.agents.register(originChild) + + const startingSession = ctx.sessions.create(sid('session-starting-child'), { + meta: { cwd: '/proj', parentSession: parent.id }, + }) + const startingChild = { id: startingSession.id, session: startingSession, status: 'idle', ctx } as Agent + ctx.agents.enter(startingChild, parent) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const stopped = await remote.cancel(request({ sessionId: originChild.id })) + expect(stopped.ok).toBe(false) + if (!stopped.ok) expect(stopped.error.code).toBe('agent-busy') + expect(cancel).not.toHaveBeenCalled() + + const queued = await remote.updateQueue(request({ + sessionId: originChild.id, + itemId: MessageId('queued-item'), + action: { kind: 'remove' }, + })) + expect(queued.ok).toBe(false) + if (!queued.ok) expect(queued.error.code).toBe('agent-busy') + expect(updateInbox).not.toHaveBeenCalled() + + const selection = await remote.selectModel(request({ + sessionId: startingChild.id, + provider: 'p', + model: 'm', + })) + expect(selection.ok).toBe(false) + if (!selection.ok) expect(selection.error.code).toBe('agent-busy') + + const create = await remote.create(request({ sessionId: originChild.id, cwd: '/proj' })) + expect(create.ok).toBe(false) + if (!create.ok) expect(create.error.code).toBe('agent-busy') + + expect(ctx.agents.get(originChild.id)).toBe(originChild) + }) + + it('does not classify an ordinary fork from an inherited ancestor descriptor', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(sid('session-ordinary-fork'), { + seed: [{ + type: 'subagent/descriptor', + seq: 0, + time: 1, + data: { version: 2, mode: 'continuable', provider: 'spawn', label: 'ancestor' }, + }], + meta: { cwd: '/proj', parentSession: sid('session-source'), seedLength: 1 }, + }) + const followup = vi.fn() + const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + ctx.agents.register(agent) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const response = await remote.prompt(promptRequest({ + sessionId: agent.id, + mode: 'queue', + content: [{ type: 'text', text: 'ordinary work' }], + })) + expect(response.ok).toBe(true) + expect(followup).toHaveBeenCalledOnce() + }) + + it('canonicalizes a supplied browser zone on the exact prompt and rejects invalid names', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(sid('session-browser-zone'), { meta: { cwd: '/proj' } }) + const followup = vi.fn() + const agent = { id: session.id, session, status: 'idle', ctx, followup } as unknown as Agent + ctx.agents.register(agent) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) + + const alias = 'US/Pacific' + const canonical = new Intl.DateTimeFormat('en-US', { timeZone: alias }) + .resolvedOptions().timeZone + const zonedRequest = promptRequest({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'zoned work' }], + clientTimeZone: alias, + }) + await expect(remote.prompt(zonedRequest)).resolves.toMatchObject({ ok: true }) + expect(followup).toHaveBeenNthCalledWith(1, expect.objectContaining({ + source: { kind: 'user', rpcId: zonedRequest.requestId, clientTimeZone: canonical }, + })) + + const utcRequest = promptRequest({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'UTC work' }], + clientTimeZone: 'UTC', + }) + await expect(remote.prompt(utcRequest)).resolves.toMatchObject({ ok: true }) + expect(followup).toHaveBeenNthCalledWith(2, expect.objectContaining({ + source: { kind: 'user', rpcId: utcRequest.requestId, clientTimeZone: 'UTC' }, + })) + + const unzonedRequest = promptRequest({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'headless work' }], + }) + await expect(remote.prompt(unzonedRequest)).resolves.toMatchObject({ ok: true }) + expect(followup).toHaveBeenNthCalledWith(3, expect.objectContaining({ + source: { kind: 'user', rpcId: unzonedRequest.requestId }, + })) + + for (const clientTimeZone of ['', ' UTC', 'CST', 'Not/A_Real_Zone']) { + const invalid = await remote.prompt(promptRequest({ + sessionId: agent.id, + mode: 'queue' as const, + content: [{ type: 'text' as const, text: 'invalid zone' }], + clientTimeZone, + })) + expect(invalid).toEqual({ + ok: false, + error: { + code: 'invalid-time-zone', + message: 'clientTimeZone must be UTC or a valid IANA Area/Location name', + details: { value: clientTimeZone }, + }, + }) + } + expect(followup).toHaveBeenCalledTimes(3) + }) +}) + +describe('degenerate composition (no persistence, no factory)', () => { + it('lists no cold rows and reports an absent point source as not found', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const listed = await remote.list(request({})) + expect(listed.ok).toBe(true) + if (listed.ok) expect(listed.value.items).toEqual([]) + + // No persistence means cold history cannot inspect a transcript. + const response = await remote.page({ + address: { kind: 'session', sessionId: sid('session-ghost') }, + throughSeq: -1, + }) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error.code).toBe('session-not-found') + } + }) + + it('maps a missing direct persistence read to session-not-found', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const inspect = vi.fn() + providePersistence(ctx, { + list: () => Promise.resolve([]), + inspect, + }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const response = await remote.page({ + address: { kind: 'session', sessionId: sid('session-missing') }, + throughSeq: -1, + }) + expect(response.ok).toBe(false) + if (!response.ok) expect(response.error.code).toBe('session-not-found') + expect(inspect).toHaveBeenCalledOnce() + }) +}) + +describe('sessions.prompt synchronous rejection', () => { + it('maps a synchronous send throw (disposed/invalid input) to agent-busy with the reason attached', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const session = ctx.sessions.create(sid('session-throwing')) + // A live structural stub whose delivery verbs throw synchronously, the + // shape a disposed loop presents at this gateway boundary. + ctx.agents.register({ + id: session.id, + session, + status: 'idle', + ctx, + followup: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, + steer: () => { throw new Error('agent "session-throwing" lifecycle disposed') }, + } as unknown as Agent) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + for (const mode of ['queue', 'steer'] as const) { + const response = await remote.prompt(promptRequest({ + sessionId: session.id, mode, content: [{ type: 'text' as const, text: 'x' }], + })) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error.code).toBe('agent-busy') + expect(response.error.message).toBe('prompt rejected') + expect(response.error.details).toEqual({ + reason: 'Error: agent "session-throwing" lifecycle disposed', + }) + } + } + }) + + it('classifies a raced cold-resume ID collision as agent-busy', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + const sessionId = sid('race-resume') + const meta: SessionHeader = header('race-resume', 1000) + providePersistence(ctx, { + list: () => Promise.resolve([meta]), + inspect: () => Promise.resolve({ meta, events: [] as SessionEvent[] }), + locate: () => undefined, + }) + // The raced winner: a live parent-owned subagent publishes the identity + // while the generic cold resume is in flight, so the resume collides. + const parentSession = ctx.sessions.create(sid('race-parent'), { meta: { cwd: '/proj' } }) + const parent = { id: parentSession.id, session: parentSession, status: 'idle', ctx } as Agent + ctx.agents.register(parent) + const childSession = ctx.sessions.create(sessionId, { + meta: { cwd: '/proj', parentSession: parent.id, origin: 'subagent' }, + }) + const child = { id: sessionId, session: childSession, status: 'idle', ctx } as unknown as Agent + vi.spyOn(ctx.agents, 'resume').mockImplementationOnce(async () => { + // The parent's `enter()` wins the identity between the pre-resume + // re-check and publication; the generic resume then collides. + ctx.agents.register(child) + throw new Error('session id already published') + }) + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + + const selection = await remote.selectModel(request({ sessionId, provider: 'p', model: 'm' })) + expect(selection.ok).toBe(false) + if (!selection.ok) { + expect(selection.error).toMatchObject({ + code: 'agent-busy', + details: { reason: 'use subagent delivery for this child session' }, + }) + } + }) +}) diff --git a/packages/api/session-controller/tests/session-fork.host.spec.ts b/packages/api/session-controller/tests/session-fork.host.spec.ts new file mode 100644 index 0000000000..06e7b5a3dd --- /dev/null +++ b/packages/api/session-controller/tests/session-fork.host.spec.ts @@ -0,0 +1,288 @@ +/** Session Controller fork boundaries, lineage, and inherited model routing. */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { LlmCallConfig } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import type { Workspace } from '@deepseek-ai/dsh-workspace' +import { + createSessionTestRemote, installSessionReadTestServices, testSessionPersistence, +} from './test-remote.ts' + +const sid = (id: string): SessionId => id as SessionId + +function request

(payload: P): P { + return payload +} + +async function composed(workspaces: readonly Workspace[] = []): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + ctx.provide('workspaceRegistry', { list: () => workspaces } as never) + ctx.agents.setFactory({ + createAgent: async (ownerCtx: Context, options: CreateAgentOptions): Promise => { + const session = ctx.sessions.create(options.sessionId, { + ...options.seed === undefined ? {} : { seed: [...options.seed] }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const agent = {} as Agent + const agentCtx = ownerCtx.extend({ agent }) + Object.assign(agent, { id: session.id, session, status: 'idle', ctx: agentCtx }) + await options.setup?.(agentCtx) + ctx.agents.register(agent) + return { agent, dispose: () => Promise.resolve() } + }, + resume: () => Promise.reject(new Error('fork test sources are live')), + }) + return ctx +} + +/** Tail turn appended after the completed ones: left open, or closed as aborted (a stopped turn). */ +type Tail = 'none' | 'open' | 'aborted' + +function liveAgent( + ctx: Context, + id: string, + turns: number, + tail: Tail = 'none', + lineage: { parentSession?: SessionId; origin?: 'subagent' } = {}, +): Session { + const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj', ...lineage } }) + for (let turn = 1; turn <= turns; turn++) { + session.append('turn/start', { turn }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `prompt ${String(turn)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + if (tail !== 'none') { + session.append('turn/start', { turn: turns + 1 }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'open prompt' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + if (tail === 'aborted') session.append('turn/end', { + turn: turns + 1, + reason: { kind: 'aborted', reason: { kind: 'user' } }, + }) + } + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + return session +} + +const remote = (ctx: Context) => createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'default-provider', model: 'default-model' }), + cwd: '/tmp', +}) + +describe('sessions.fork', () => { + it('cuts at the anchored completed turn and records lineage and cwd', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-source', 2) + const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: 1 })) + expect(response.ok).toBe(true) + if (!response.ok) return + const child = ctx.sessions.get(response.value.sessionId) + expect(child?.events.map(event => event.type)).toEqual([ + 'turn/start', 'user/message', 'turn/end', 'session/end-seed', + ]) + expect(child?.header.parentSession).toBe(source.id) + expect(child?.header.cwd).toBe('/proj') + await ctx.fiber.dispose() + }) + + it('attaches a subagent fork to its nearest workspace-owning ancestor', async () => { + const accounted: SessionId[] = [] + const attachSession = vi.fn<(sessionId: SessionId) => Promise>() + .mockResolvedValue(undefined) + const workspace = { + sessionIds: accounted, + attachSession, + } as unknown as Workspace + const ctx = await composed([workspace]) + const owner = liveAgent(ctx, 'session-owner', 1) + accounted.push(owner.id) + const child = liveAgent(ctx, 'session-child', 1, 'none', { + parentSession: owner.id, + origin: 'subagent', + }) + const grandchild = liveAgent(ctx, 'session-grandchild', 1, 'none', { + parentSession: child.id, + origin: 'subagent', + }) + vi.spyOn(ctx.sessionQuery, 'traceSession').mockResolvedValue({ + target: { header: grandchild.header, live: true, persisted: false }, + ancestors: [ + { header: child.header, live: true, persisted: false }, + { header: owner.header, live: true, persisted: false }, + ], + descendants: [], + complete: true, + root: { header: owner.header, live: true, persisted: false }, + }) + + const response = await remote(ctx).fork(request({ sessionId: grandchild.id })) + + expect(response.ok).toBe(true) + if (!response.ok) return + expect(attachSession).toHaveBeenCalledWith(response.value.sessionId) + expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({ + parentSession: grandchild.id, + cwd: '/proj', + }) + expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('forks a persisted subagent without resuming its Agent', async () => { + const ctx = await composed() + const sourceId = sid('session-cold-subagent') + const parentId = sid('session-cold-parent') + const header: SessionHeader = { + version: 0, + id: sourceId, + createdAt: 1, + cwd: '/proj', + parentSession: parentId, + origin: 'subagent', + } + const events = [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } }, + { + type: 'user/message', + seq: 1, + time: 2, + data: createUserMessage({ content: [{ type: 'text', text: 'work' }], source: { kind: 'user' } }), + surfaceOp: 'append', + }, + { type: 'turn/end', seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' } } }, + ] as SessionEvent[] + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events }), + }) as never) + const resume = vi.spyOn(ctx.agents, 'resume') + + const response = await remote(ctx).fork(request({ sessionId: sourceId })) + + expect(response.ok).toBe(true) + if (!response.ok) return + expect(resume).not.toHaveBeenCalled() + expect(ctx.agents.get(sourceId)).toBeUndefined() + expect(ctx.sessions.get(response.value.sessionId)?.header).toMatchObject({ + parentSession: sourceId, + cwd: '/proj', + }) + expect(ctx.sessions.get(response.value.sessionId)?.header.origin).toBeUndefined() + await ctx.fiber.dispose() + }) + + it('uses the last completed turn only for omitted and past-end anchors', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-tail', 2, 'open') + const proxy = remote(ctx) + const expectedTypes = [ + 'turn/start', 'user/message', 'turn/end', + 'turn/start', 'user/message', 'turn/end', + 'session/end-seed', + ] + const omitted = await proxy.fork(request({ sessionId: source.id })) + expect(omitted.ok).toBe(true) + if (omitted.ok) { + expect(ctx.sessions.get(omitted.value.sessionId)?.events.map(event => event.type)) + .toEqual(expectedTypes) + } + const pastEnd = await proxy.fork(request({ sessionId: source.id, atSeq: 999 })) + expect(pastEnd.ok).toBe(true) + if (pastEnd.ok) { + expect(ctx.sessions.get(pastEnd.value.sessionId)?.events.map(event => event.type)) + .toEqual(expectedTypes) + } + await ctx.fiber.dispose() + }) + + it('rejects invalid fork anchors before reading or creating a Session', async () => { + const ctx = await composed() + const proxy = remote(ctx) + + for (const atSeq of [-1, 0.5]) { + await expect(proxy.fork(request({ sessionId: sid('missing'), atSeq }))) + .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + } + expect(ctx.sessions.list()).toEqual([]) + await ctx.fiber.dispose() + }) + + it('cuts through an aborted turn: stopped is closed, not open', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-aborted', 1, 'aborted') + // What a stopped message's fork button anchors on: the frozen node sits + // one event before its turn/end, floored client-side to that event's seq. + const anchor = (source.events.at(-1)?.seq ?? 0) - 1 + const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + expect(response.ok).toBe(true) + if (!response.ok) return + expect(ctx.sessions.get(response.value.sessionId)?.events.map(event => event.type)).toEqual([ + 'turn/start', 'user/message', 'turn/end', + 'turn/start', 'user/message', 'turn/end', + 'session/end-seed', + ]) + await ctx.fiber.dispose() + }) + + it('rejects an in-log anchor whose turn is still open', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-open', 1, 'open') + const anchor = source.events.at(-1)?.seq ?? 0 + const response = await remote(ctx).fork(request({ sessionId: source.id, atSeq: anchor })) + expect(response).toMatchObject({ + ok: false, + error: { code: 'fork-unavailable', details: { sessionId: source.id } }, + }) + if (!response.ok) expect(response.error.message).toMatch(/has not completed/) + await ctx.fiber.dispose() + }) + + it('installs the latest logged model selection before the child can run', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-routed', 1) + source.append('request/header', { + header: { + config: { + provider: 'inherited-provider', + model: 'inherited-model', + reasoningEffort: ReasoningEffortId('high'), + }, + }, + reason: 'initial', + }) + const response = await remote(ctx).fork(request({ sessionId: source.id })) + expect(response.ok).toBe(true) + if (!response.ok) return + const child = ctx.agents.get(response.value.sessionId) + if (child === undefined) throw new Error('fork did not publish the child agent') + const assembly = await child.ctx.systemPrompt.assemble() + expect(assembly.variables).toMatchObject({ + provider: 'inherited-provider', + model: 'inherited-model', + }) + const fallback: LlmCallConfig = { provider: 'default-provider', model: 'default-model' } + await expect(agentEvents(child.ctx, child).waterfall( + 'agent/request', { turn: 1, step: 0, signal: new AbortController().signal }, () => Promise.resolve(fallback), + )).resolves.toMatchObject({ + provider: 'inherited-provider', + model: 'inherited-model', + reasoningEffort: 'high', + }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/session-history-journal.host.spec.ts b/packages/api/session-controller/tests/session-history-journal.host.spec.ts new file mode 100644 index 0000000000..d42639922e --- /dev/null +++ b/packages/api/session-controller/tests/session-history-journal.host.spec.ts @@ -0,0 +1,392 @@ +/** Raw Session journal transport and message-aligned pagination coverage. */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import { decodeStorageRecord, type ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import { ToolCallId, createMessage, createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { SessionHistoryController } from '@deepseek-ai/dsh-api-session-controller/src/history.ts' +import type { + ChunkRowEvent, + SessionFollowFrame, + SessionPage, + SessionWireEvent, +} from '@deepseek-ai/dsh-api-session-controller/types' +import { createSessionTestRemote, installSessionReadTestServices } from './test-remote.ts' + +/** Append a production-shaped human prompt to the session surface. */ +function appendUserText(session: Session, text: string): SessionEvent { + return session.append('user/message', createUserMessage({ + content: [{ type: 'text', text }], source: { kind: 'user' }, + }), { surfaceOp: 'append' }) +} + +/** Append a production-shaped assistant message to the session surface. */ +function appendAssistantText(session: Session, text: string, step: number): SessionEvent { + return session.append('assistant/message', { + turn: 1, + step, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'p', model: 'm' }, + }), + }, { surfaceOp: 'append' }) +} + +/** + * Append a plugin-owned log-only event. The host proxy is projection-only, so it + * declares no compaction vocabulary; the cast writes the real event shape without + * depending on the owning package. + */ +function appendExtension(session: Session, type: string, data: unknown): SessionEvent { + return (session.append as unknown as (type: string, data: unknown) => SessionEvent)(type, data) +} + +async function harness(): Promise<{ ctx: Context }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + installSessionReadTestServices(ctx) + return { ctx } +} + +/** Drain one Session follow until `count` event frames arrive. */ +async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, +): Promise { + const frames: SessionFollowFrame[] = [] + for await (const frame of iterable) { + frames.push(frame) + if (frames.filter(candidate => candidate.type === 'event').length >= count) abort.abort() + } + return frames +} + +/** Open follow and wait until its cursor is fixed before appending fixtures. */ +async function openFollow( + history: SessionHistoryController, + sessionId: SessionId, + signal: AbortSignal, +): Promise> { + const iterator = history.follow({ + address: { kind: 'session', sessionId }, + }, signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'snapshot' }, + }) + return { [Symbol.asyncIterator]: () => iterator } +} + +/** Expand packed page records for assertions over the logical journal. */ +function pageEvents(page: SessionPage): SessionWireEvent[] { + return page.records.flatMap(record => record.type === 'event' + ? [record.event] + : decodeStorageRecord(chunkRow(record.event)).map(event => event as unknown as SessionWireEvent)) +} + +function chunkRow(event: ChunkRowEvent): ChunkRow { + switch (event.type) { + case 'chunkrow/text-chunks': + return { type: 'text-chunks', seq0: event.seq, time0: event.time, data: event.data } + case 'chunkrow/reasoning-chunks': + return { type: 'reasoning-chunks', seq0: event.seq, time0: event.time, data: event.data } + case 'chunkrow/tool-call-chunks': + return { type: 'tool-call-chunks', seq0: event.seq, time0: event.time, data: event.data } + } +} + +describe('Session history raw journal', () => { + it('follows raw tool events and preserves result metadata without a Tools service', async () => { + const { ctx } = await harness() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const abort = new AbortController() + const stream = await openFollow(history, session.id, abort.signal) + const collected = collect(stream, 2, abort) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: ToolCallId('raw-call'), name: 'custom', arguments: '{malformed', + }) + const result = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: ToolCallId('raw-call'), + content: [{ type: 'text', text: 'raw output' }], + isError: false, + }), + meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] }, + }, { surfaceOp: 'append' }) + + const frames = await collected + expect(frames).toEqual([ + { type: 'event', event: call }, + { type: 'event', event: result }, + ]) + expect((frames[1] as Extract).event.data) + .toMatchObject({ meta: { nested: { count: 2 }, paths: ['a.ts', 'b.ts'] } }) + }) + + it('follows live results without rescanning Session history', async () => { + const { ctx } = await harness() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const abort = new AbortController() + const stream = await openFollow(history, session.id, abort.signal) + const iterator = stream[Symbol.asyncIterator]() + + session.append('tool/call', { + turn: 1, step: 1, callId: ToolCallId('live-fast'), name: 'term', arguments: '{"cmd":"pwd"}', + }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', event: { type: 'tool/call', data: { callId: 'live-fast' } } }, + }) + + const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + throw new Error('live result rescanned Session history') + }) + try { + session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: ToolCallId('live-fast'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', event: { type: 'tool/result', data: { message: { source: { callId: 'live-fast' } } } } }, + }) + } finally { + events.mockRestore() + abort.abort() + await iterator.next() + await ctx.fiber.dispose() + } + }) + + it('serves raw call and result entries without parsing tool arguments', async () => { + const { ctx } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const start = session.append('turn/start', { turn: 1 }) + const call = session.append('tool/call', { + turn: 1, step: 1, callId: ToolCallId('history-call'), name: 'custom', arguments: '{broken', + }) + const result = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: ToolCallId('history-call'), + content: [{ type: 'text', text: 'failed raw output' }], + isError: true, + }), + meta: { persisted: true, count: 3 }, + }, { surfaceOp: 'append' }) + + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + }) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + expect(response.value.records).toEqual([ + { type: 'event', event: start }, + { type: 'event', event: call }, + { type: 'event', event: result }, + ]) + }) + + it('counts only append-origin messages toward maxMessages and keeps each compaction summary with its replacement', async () => { + const { ctx } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + const first = appendUserText(session, 'first prompt') + appendAssistantText(session, 'first reply', 1) + const third = appendUserText(session, 'second prompt') + appendAssistantText(session, 'second reply', 2) + const shadowed = [...session.surface.nodes] + // A compaction transaction: a log-only summary record immediately followed by the + // replacement that shadows the range. + const summary = appendExtension(session, 'compaction/summary', { + summary: [{ type: 'text', text: 'summary' }], + shadowedRange: { start: shadowed[0], end: shadowed.at(-1) }, + shadowedSeqs: shadowed, + shadowedTokenCount: 0, + provider: 'p', + model: 'm', + }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'summary' }], + source: { kind: 'plugin', plugin: 'compact' }, + }), { + surfaceOp: { op: 'replace', start: shadowed[0] as number, end: shadowed.at(-1) as number }, + sourceEventSeqs: [...shadowed, summary.seq], + }) + + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + maxMessages: 2, + }) + if (!response.ok) throw new Error('unreachable') + const page = pageEvents(response.value) + // Two append-origin messages fill the page even though a replacement copy of + // the same event type sits in the window: the copy is model-only. + const messages = page.filter(event => event.type === 'user/message' || event.type === 'assistant/message') + expect(messages.map(event => event.seq)).toEqual([third.seq, third.seq + 1, third.seq + 3]) + expect(page.some(event => event.seq === first.seq)).toBe(false) + expect(response.value.hasMore).toBe(true) + // The range stays contiguous, so the checkpoint's summary record is readable on + // the same page as the checkpoint itself. + const summaryIndex = page.findIndex(event => event.seq === summary.seq) + expect(summaryIndex).toBeGreaterThan(-1) + expect(page[summaryIndex + 1]?.seq).toBe(summary.seq + 1) + expect(page.map(event => event.seq)).toEqual(page.map((_event, index) => third.seq + index)) + }) + + it('paginates a message with many provenance sources without variadic argument expansion', async () => { + const { ctx } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + const sources = Array.from({ length: 128 }, () => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'x' }, + }).seq) + const message = session.append('assistant/message', { + turn: 1, + step: 1, + message: createMessage({ + role: 'assistant', + content: [{ type: 'text', text: 'x'.repeat(sources.length) }], + source: { kind: 'model', provider: 'p', model: 'm' }, + }), + }, { surfaceOp: 'append', sourceEventSeqs: sources }) + + const scalarMin = Math.min + const min = vi.spyOn(Math, 'min').mockImplementation((...values) => { + if (values.length > 2) throw new RangeError('variadic minimum rejected by regression harness') + return scalarMin(...values) + }) + try { + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: message.seq, + maxMessages: 1, + }) + if (!response.ok) throw new Error('unreachable') + expect(pageEvents(response.value).map(event => event.seq)).toEqual([...sources, message.seq]) + expect(response.value.records.filter(record => record.type === 'chunks')).toHaveLength(1) + expect(response.value.hasMore).toBe(true) + } finally { + min.mockRestore() + } + }) + + it('encodes reasoning and tool-call runs as aligned chunk events', async () => { + const { ctx } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const reasoning = [0, 1, 2].map(index => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: `r${String(index)}` }, + })) + const callId = ToolCallId('packed-call') + const toolCall = [0, 1, 2].map(index => session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'tool-call-delta', index: 1, id: callId, argumentsDelta: `a${String(index)}` }, + })) + + const response = await remote.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: session.seq - 1, + }) + if (!response.ok) throw new Error('unreachable') + expect(response.value.records).toEqual([ + { + type: 'chunks', + event: { + type: 'chunkrow/reasoning-chunks', + seq: reasoning[0]?.seq, + time: reasoning[0]?.time, + data: { + turn: 1, + step: 1, + index: 0, + dt: reasoning.slice(1).map((event, index) => event.time - (reasoning[index]?.time ?? 0)), + texts: ['r0', 'r1', 'r2'], + }, + }, + }, + { + type: 'chunks', + event: { + type: 'chunkrow/tool-call-chunks', + seq: toolCall[0]?.seq, + time: toolCall[0]?.time, + data: { + turn: 1, + step: 1, + index: 1, + id: callId, + dt: toolCall.slice(1).map((event, index) => event.time - (toolCall[index]?.time ?? 0)), + args: ['a0', 'a1', 'a2'], + }, + }, + }, + ]) + await ctx.fiber.dispose() + }) + + it('follows a result after turn/end without reading the addressed Session log', async () => { + const { ctx } = await harness() + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + const history = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + const abort = new AbortController() + const stream = await openFollow(history, session.id, abort.signal) + const iterator = stream[Symbol.asyncIterator]() + + session.append('turn/start', { turn: 1 }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', event: { type: 'turn/start' } }, + }) + session.append('tool/call', { turn: 1, step: 1, callId: ToolCallId('c-late'), name: 'term', arguments: '{"cmd":"tail"}' }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', event: { type: 'tool/call' } }, + }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'event', event: { type: 'turn/end' } }, + }) + const events = vi.spyOn(session, 'events', 'get').mockImplementation(() => { + throw new Error('live result rescanned Session history') + }) + try { + const result = session.append('tool/result', { + turn: 1, step: 1, + message: createToolResultMessage({ + callId: ToolCallId('c-late'), + content: [{ type: 'text', text: 'ok' }], + isError: false, + }), + }, { surfaceOp: 'append' }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { type: 'event', event: result }, + }) + } finally { + events.mockRestore() + abort.abort() + await iterator.next() + await ctx.fiber.dispose() + } + }) +}) diff --git a/packages/api/session-controller/tests/session-list-blank.host.spec.ts b/packages/api/session-controller/tests/session-list-blank.host.spec.ts new file mode 100644 index 0000000000..cdfbbda39b --- /dev/null +++ b/packages/api/session-controller/tests/session-list-blank.host.spec.ts @@ -0,0 +1,74 @@ +/** + * The summary blank bit means "conversation not started" (no turn has run), + * not "log empty": standalone plugin events — command lifecycle records, + * plan/mode, permission knob events, session titles — never flip it, so running /plan or /goal on a + * fresh session keeps it list-hidden and reusable, while the first accepted + * prompt's turn/start clears it. The host/session-added frame shares the + * same predicate function (covered by the workspace spec's frame assertion). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import { CommandId } from '@deepseek-ai/dsh-commands/brand' +// Side-effect type imports: the configuration-event SessionEventMap merges. +import type {} from '@deepseek-ai/dsh-permission-presets' +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' + +async function harness(): Promise<{ ctx: Context; remote: TestSessionRemote; attach: (session: Session) => void }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return { + ctx, + remote: createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }), + attach: (session) => { + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + }, + } +} + +/** Append the standalone (non-conversation) event family a fresh session can accumulate. */ +function appendStandalone(session: Session): void { + session.append('command/run', { + commandId: CommandId('blank-cmd-1'), name: 'plan', args: '', source: { kind: 'user' }, + }) + session.append('plan/mode', { active: true }) + session.append('command/done', { commandId: CommandId('blank-cmd-1'), kind: 'success', text: 'Plan mode on.' }) + session.append('session/title', { + title: 'standalone title', messageSeqs: [], source: { kind: 'fallback' }, + }) + // Permission configuration events from a /permission switch on a fresh session. + session.append('permission/preset', { preset: 'danger-full-access' }) + session.append('sandbox/mode', { mode: 'danger-full-access' }) +} + +async function listBlank(remote: TestSessionRemote, id: string): Promise { + const result = await remote.list({}) + if (!result.ok) throw new Error('list failed') + return result.value.items.find(item => item.sessionId === id)?.blank +} + +describe('summary blank = conversation not started', () => { + it('standalone events (command lifecycle, plan/mode, title) keep the session blank', async () => { + const { ctx, remote, attach } = await harness() + const session = ctx.sessions.create() + attach(session) + expect(await listBlank(remote, session.id)).toBe(true) + appendStandalone(session) + expect(await listBlank(remote, session.id)).toBe(true) + }) + + it('the first turn clears blank', async () => { + const { ctx, remote, attach } = await harness() + const session = ctx.sessions.create() + attach(session) + appendStandalone(session) + session.append('turn/start', { turn: 0 }) + expect(await listBlank(remote, session.id)).toBe(false) + }) +}) diff --git a/packages/api/session-controller/tests/session-models.host.spec.ts b/packages/api/session-controller/tests/session-models.host.spec.ts new file mode 100644 index 0000000000..d203585459 --- /dev/null +++ b/packages/api/session-controller/tests/session-models.host.spec.ts @@ -0,0 +1,694 @@ +/** + * Session Controller model-directory and selection behavior: dynamic provider grouping, + * provider-local catalog failures, logged-selection restoration without stale + * catalog injection, advisory pass-through models, and the prompt-assembly + * boundary for a running selection change. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AttachmentStore from '@deepseek-ai/dsh-attachment' +import LlmRuntime, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' +import type { + GenerateOptions, LlmCallConfig, LlmCallConfigAdapterDefaults, LlmModelInfo, + LlmModelReasoningInfo, LlmProviderInfo, LlmResolvedModelInfo, StreamChunk, + UserMessage, +} from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPromptRequest, SessionRequestId } from '../src/types.ts' +import { ApiSessionAgentController } from '../src/agent.ts' +import { buildModelCatalog } from '../src/catalog.ts' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { createSessionTestRemote } from './test-remote.ts' + +function request

(payload: P): P { + return payload +} + +let nextRequestId = 1 +function promptRequest( + payload: Omit, +): SessionPromptRequest { + return { + ...payload, + requestId: `models-${String(nextRequestId++)}` as SessionRequestId, + } +} + +class CatalogAdapter extends LlmAdapter { + constructor( + private readonly name: string, + private readonly models: readonly LlmModelInfo[] | Error, + private readonly reasoning?: LlmModelReasoningInfo, + private readonly exactError?: Error, + ) { + super() + } + + override providerInfo(provider: string): LlmProviderInfo { + return { id: provider, name: this.name } + } + + override listModels(): Promise { + return this.models instanceof Error + ? Promise.reject(this.models) + : Promise.resolve(this.models) + } + + override resolveModel(provider: string, model: string): Promise { + if (this.exactError !== undefined) return Promise.reject(this.exactError) + return Promise.resolve({ + provider, + id: model, + name: model, + ...this.reasoning === undefined ? {} : { reasoning: this.reasoning }, + }) + } + + override async *stream(_options: GenerateOptions): AsyncIterable { + // Catalog tests never enter provider streaming. + } +} + +const REASONING: LlmModelReasoningInfo = { + efforts: [ + { id: ReasoningEffortId('off'), name: 'Off' }, + { id: ReasoningEffortId('high'), name: 'High' }, + { id: ReasoningEffortId('max'), name: 'Max' }, + ], + defaultEffort: ReasoningEffortId('high'), +} + +async function harness(logged?: { + provider: string + model: string + reasoningEffort?: ReasoningEffortId + adapterDefaults?: LlmCallConfigAdapterDefaults +}): Promise<{ + ctx: Context + agent: Agent + sessionId: SessionId +}> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SystemPrompt, { persona: '' }) + await ctx.plugin(LlmRuntime) + await ctx.plugin(AgentRegistry) + ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', [ + { provider: 'deepseek-official', id: 'deepseek-chat', name: 'DeepSeek Chat' }, + { provider: 'deepseek-official', id: 'deepseek-reasoner', name: 'DeepSeek Reasoner', description: 'Reasoning model' }, + ], REASONING)) + ctx.llm.registerAdapter(['broken'], new CatalogAdapter('Broken Provider', new Error('catalog offline'))) + ctx.llm.registerAdapter(['metadata-broken'], new CatalogAdapter('Metadata Broken', [ + { provider: 'metadata-broken', id: 'listed', name: 'Listed' }, + ], undefined, new Error('reasoning metadata offline'))) + ctx.llm.registerAdapter(['remote-rejected'], new CatalogAdapter( + 'Remote Rejected', + [], + undefined, + new TypertRemoteFailure({ + code: 'fixture-rejected', + message: 'fixture rejected the selection', + details: { provider: 'remote-rejected' }, + }), + )) + ctx.llm.registerAdapter(['empty'], new CatalogAdapter('Empty Provider', [])) + ctx.llm.registerAdapter(['duplicate'], new CatalogAdapter('Duplicate Provider', [ + { provider: 'duplicate', id: 'same', name: 'Same' }, + { provider: 'duplicate', id: 'same', name: 'Same Again' }, + ])) + const session = ctx.sessions.create() + if (logged !== undefined) { + const { adapterDefaults, ...config } = logged + session.append('request/header', { + header: { config, ...adapterDefaults === undefined ? {} : { adapterDefaults } }, + reason: 'initial', + }) + } + const agent = { + id: session.id, + session, + status: 'running', + ctx, + inbox: { nextTurn: [], nextStep: [] }, + } as unknown as Agent + ctx.agents.register(agent) + return { ctx, agent, sessionId: session.id } +} + +function expectValue(result: { ok: true; value: T } | { ok: false }): T { + if (!result.ok) throw new Error('expected successful response') + return result.value +} + +function registerTextOnly(ctx: Context): void { + ctx.llm.registerAdapter(['text-only'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ provider, id: model, name: model, inputModalities: ['text'] }) + } + }('Text Only', [])) +} + +/** Resolve the Client-visible next selection from durable state and the Host default. */ +function currentSelection(ctx: Context, sessionId: SessionId) { + const session = ctx.sessions.get(sessionId) + if (session === undefined) throw new Error('expected a live test Session') + return ctx.sessionProjections.snapshot(session).values.modelSelection?.next + ?? ctx.agentDefaultModel.currentSelection() +} + +describe('Web session model selection', () => { + it('validates an ordered image batch before persisting any member', async () => { + const { ctx, agent, sessionId } = await harness() + const validateImage = vi.fn((_input: { data: Uint8Array }) => Promise.resolve()) + const saveImage = vi.fn((input: { data: Uint8Array; mediaType: 'image/png'; name?: string }) => Promise.resolve({ + attachmentId: `att-${String(input.data[0])}`, + mediaType: input.mediaType, + bytes: input.data.byteLength, + width: 1, + height: 1, + ...input.name === undefined ? {} : { name: input.name }, + })) + const attachments = { + imageLimits: { + maxImageBytes: 4, + maxImagesPerMessage: 2, + maxMessageImageBytes: 4, + maxImagePixels: 4, + maxImageDimension: 2000, + mediaTypes: ['image/png'], + }, + validateImage, + saveImage, + } + ctx.provide('attachments', Object.setPrototypeOf(attachments, AttachmentStore.prototype) as never) + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + + const result = await remote.prompt(promptRequest({ + sessionId, + mode: 'queue' as const, + content: [ + { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', name: 'first.png' }, + { type: 'text' as const, text: 'compare' }, + { type: 'image' as const, mediaType: 'image/png' as const, data: 'Ag==' }, + ], + })) + expect(result.ok).toBe(true) + expect(validateImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect(saveImage.mock.calls.map(([input]) => [...input.data])).toEqual([[1], [2]]) + expect((followup.mock.calls[0]?.[0] as UserMessage).content).toEqual([ + { + type: 'image', + attachment: { + attachmentId: 'att-1', mediaType: 'image/png', bytes: 1, width: 1, height: 1, name: 'first.png', + }, + }, + { type: 'text', text: 'compare' }, + { type: 'image', attachment: { attachmentId: 'att-2', mediaType: 'image/png', bytes: 1, width: 1, height: 1 } }, + ]) + + const denied = await remote.prompt(promptRequest({ + sessionId, + mode: 'queue' as const, + content: Array.from({ length: 3 }, () => ({ + type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==', + })), + })) + expect(denied).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'TOO_MANY_IMAGES' } }, + }) + expect(saveImage).toHaveBeenCalledTimes(2) + await ctx.fiber.dispose() + }) + + it('allows a text-only selection while durable or pending images remain available for later models', async () => { + const { ctx, agent, sessionId } = await harness() + registerTextOnly(ctx) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + const image = { + type: 'image' as const, + attachment: { attachmentId: 'att-history', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 }, + } + const imageEvent = agent.session.append('user/message', { + id: 'image-message', role: 'user', source: { kind: 'user' }, content: [image], + } as never, { surfaceOp: 'append' }) + expect(expectValue(await remote.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) + + agent.session.append('user/message', { + id: 'summary', role: 'user', source: { kind: 'plugin', plugin: 'compact' }, + content: [{ type: 'text', text: 'image summarized' }], + } as never, { + surfaceOp: { op: 'replace', start: imageEvent.seq, end: imageEvent.seq }, + sourceEventSeqs: [imageEvent.seq], + }) + ;(agent.inbox.nextTurn as UserMessage[]).push({ + id: 'pending-image', role: 'user', source: { kind: 'user' }, content: [image], + } as never) + expect(expectValue(await remote.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))).selected).toEqual({ provider: 'text-only', model: 'plain' }) + await ctx.fiber.dispose() + }) + + it('authorizes attachment bytes only when the session event stream references the id', async () => { + const { ctx, agent, sessionId } = await harness() + const ref = { + attachmentId: 'att-authorized', mediaType: 'image/png' as const, bytes: 2, width: 1, height: 1, + } + const readImage = vi.fn(() => Promise.resolve({ ref, data: Uint8Array.of(1, 2) })) + ctx.provide('attachments', { readImage } as never) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + agent.session.append('agent/inbox/spliced', { + target: 'next-turn', + start: 0, + inserted: [{ + id: 'queued-image', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: ref }], + }], + } as never) + + const allowed = await remote.attachment(request({ + sessionId, attachmentId: 'att-authorized' as never, + })) + expect(allowed).toMatchObject({ ok: true, value: { attachment: ref, data: 'AQI=' } }) + const denied = await remote.attachment(request({ + sessionId, attachmentId: 'att-other' as never, + })) + expect(denied).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'ATTACHMENT_NOT_REFERENCED' } }, + }) + expect(readImage).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + it('groups successful providers and leaves an unlisted current selection out of the catalog', async () => { + const { ctx, sessionId } = await harness({ + provider: 'deepseek-official', + model: 'private-preview', + reasoningEffort: ReasoningEffortId('max'), + }) + createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + + const catalog = await buildModelCatalog(ctx) + expect(currentSelection(ctx, sessionId)).toEqual({ + provider: 'deepseek-official', + model: 'private-preview', + reasoningEffort: 'max', + }) + expect(catalog.groups).toEqual([{ + id: 'deepseek-official', + name: 'DeepSeek', + models: [ + { id: 'deepseek-chat', name: 'DeepSeek Chat', reasoning: REASONING }, + { + id: 'deepseek-reasoner', + name: 'DeepSeek Reasoner', + description: 'Reasoning model', + reasoning: REASONING, + }, + ], + }]) + expect(catalog.failures).toEqual([ + { id: 'broken', name: 'Broken Provider', message: 'catalog offline' }, + { id: 'metadata-broken', name: 'Metadata Broken', message: 'reasoning metadata offline' }, + { + id: 'duplicate', + name: 'Duplicate Provider', + message: 'adapter returned invalid or duplicate model metadata for provider "duplicate"', + }, + ]) + await ctx.fiber.dispose() + }) + + it('preserves optional catalog metadata and string provider failures', async () => { + const { ctx } = await harness() + ctx.llm.registerAdapter(['plain'], new CatalogAdapter('Plain', [ + { provider: 'plain', id: 'plain-model', name: 'Plain Model' }, + ])) + ctx.llm.registerAdapter(['described-reasoning'], new CatalogAdapter('Described Reasoning', [ + { provider: 'described-reasoning', id: 'reasoning-model', name: 'Reasoning Model' }, + ], { + efforts: [{ id: ReasoningEffortId('high'), name: 'High', description: 'More thinking' }], + })) + ctx.llm.registerAdapter(['string-failure'], new class extends CatalogAdapter { + override listModels(): Promise { + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. + return Promise.reject('string catalog failure') + } + }('String Failure', [])) + createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + + const catalog = await buildModelCatalog(ctx) + expect(catalog.groups).toEqual(expect.arrayContaining([ + { id: 'plain', name: 'Plain', models: [{ id: 'plain-model', name: 'Plain Model' }] }, + { + id: 'described-reasoning', + name: 'Described Reasoning', + models: [{ + id: 'reasoning-model', + name: 'Reasoning Model', + reasoning: { + efforts: [{ id: 'high', name: 'High', description: 'More thinking' }], + }, + }], + }, + ])) + expect(catalog.failures).toContainEqual({ + id: 'string-failure', name: 'String Failure', message: 'string catalog failure', + }) + await ctx.fiber.dispose() + }) + + it('accepts an advisory-unlisted model, rejects an unavailable provider, and switches only after the next assembly', async () => { + const { ctx, agent, sessionId } = await harness() + const remote = createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), cwd: '/tmp' }) + const seed: LlmCallConfig = { provider: 'seed', model: 'seed', temperature: 0.2 } + const signal = new AbortController().signal + + expect(currentSelection(ctx, sessionId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + + const selected = expectValue(await remote.selectModel(request({ + sessionId, + provider: 'deepseek-official', + model: 'private-preview', + reasoningEffort: 'max', + }))) + expect(selected.selected).toEqual({ + provider: 'deepseek-official', + model: 'private-preview', + reasoningEffort: 'max', + }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', { turn: 1, step: 0, signal }, () => Promise.resolve(seed), + )).resolves.toEqual(seed) + + expect((await ctx.systemPrompt.assemble()).variables) + .toMatchObject({ provider: 'deepseek-official', model: 'private-preview' }) + await expect(agentEvents(ctx, agent).waterfall( + 'agent/request', { turn: 1, step: 1, signal }, () => Promise.resolve(seed), + )).resolves.toMatchObject({ + provider: 'deepseek-official', + model: 'private-preview', + reasoningEffort: 'max', + }) + + const unsupported = await remote.selectModel(request({ + sessionId, + provider: 'deepseek-official', + model: 'private-preview', + reasoningEffort: 'medium', + })) + expect(unsupported).toMatchObject({ + ok: false, + error: { + code: 'model-unavailable', + message: 'provider "deepseek-official" model "private-preview" does not support reasoning effort "medium"', + }, + }) + + const rejected = await remote.selectModel(request({ + sessionId, + provider: 'missing', + model: 'model', + })) + expect(rejected).toEqual({ + ok: false, + error: { + code: 'model-unavailable', + message: 'no adapter registered for provider "missing"', + details: { provider: 'missing', model: 'model' }, + }, + }) + expect(await remote.selectModel(request({ + sessionId, + provider: 'remote-rejected', + model: 'model', + }))).toEqual({ + ok: false, + error: { + code: 'fixture-rejected', + message: 'fixture rejected the selection', + details: { provider: 'remote-rejected' }, + }, + }) + expect(currentSelection(ctx, sessionId)) + .toEqual({ provider: 'deepseek-official', model: 'private-preview', reasoningEffort: 'max' }) + await ctx.fiber.dispose() + }) + + it('reads the Agent default live for a session whose log names no selection', async () => { + const { ctx, sessionId } = await harness() + let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } + createSessionTestRemote(ctx, { + defaultModelSelection: () => stored, + cwd: '/tmp', + }) + + expect(currentSelection(ctx, sessionId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + // The default moving after the session exists still reaches it: New + // Session reuses a blank session rather than minting another, so a seed + // captured at creation would show the superseded model there. + stored = { provider: 'deepseek-official', model: 'deepseek-reasoner' } + expect(currentSelection(ctx, sessionId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-reasoner' }) + await ctx.fiber.dispose() + }) + + it('keeps a session on its logged selection when the Agent default differs', async () => { + const { ctx, sessionId } = await harness({ + provider: 'deepseek-official', + model: 'deepseek-chat', + }) + let stored = { provider: 'deepseek-official', model: 'deepseek-chat' } + createSessionTestRemote(ctx, { + defaultModelSelection: () => stored, + cwd: '/tmp', + }) + + stored = { provider: 'duplicate', model: 'same' } + expect(currentSelection(ctx, sessionId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + await ctx.fiber.dispose() + }) + + it('does not reinterpret an adapter-owned reasoning default as an explicit Web selection', async () => { + const { ctx, agent } = await harness({ + provider: 'deepseek-official', + model: 'deepseek-chat', + reasoningEffort: ReasoningEffortId('high'), + adapterDefaults: { reasoningEffort: true }, + }) + createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'duplicate', model: 'same' }), + cwd: '/tmp', + }) + + expect(new ApiSessionAgentController(ctx).selectionFor(agent).current) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat' }) + await ctx.fiber.dispose() + }) + + it('saves an accepted selection as the default and survives a storage failure', async () => { + const { ctx, sessionId } = await harness() + const saved: unknown[] = [] + let reject = false + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + saveDefaultModelSelection: (selection) => { + saved.push(selection) + return reject ? Promise.reject(new Error('read-only document')) : Promise.resolve() + }, + cwd: '/tmp', + }) + + expectValue(await remote.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max', + }))) + expect(saved).toEqual([ + { provider: 'deepseek-official', model: 'deepseek-reasoner', reasoningEffort: 'max' }, + ]) + + // A refused selection never becomes anyone's default. + await remote.selectModel(request({ sessionId, provider: 'missing', model: 'model' })) + expect(saved).toHaveLength(1) + + // Storage failing is not the selection failing: the switch already applies + // to this session, so the call still succeeds. + reject = true + const stillAccepted = expectValue(await remote.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-chat', + }))) + expect(stillAccepted.selected).toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + expect(currentSelection(ctx, sessionId)) + .toEqual({ provider: 'deepseek-official', model: 'deepseek-chat', reasoningEffort: 'high' }) + await ctx.fiber.dispose() + }) + + it('refuses a prompt no adapter can route, and reports it on the directory', async () => { + const { ctx, sessionId } = await harness() + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + cwd: '/tmp', + }) + + // The client disabling its input is an affordance; this method stays + // callable, so the refusal has to live here. + const refused = await remote.prompt(promptRequest({ + sessionId, mode: 'queue' as const, content: [{ type: 'text' as const, text: 'hi' }], + })) + expect(refused).toMatchObject({ + ok: false, + error: { code: 'model-unavailable', details: { provider: 'deleted-gateway', model: 'deleted-model' } }, + }) + const unavailableCatalog = await buildModelCatalog(ctx) + expect(unavailableCatalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(false) + + // An advisory-unlisted model on a live route is NOT this: the route + // serves it, so the prompt goes through and nothing blocks. + expectValue(await remote.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'unlisted-but-served', + }))) + const catalog = await buildModelCatalog(ctx) + expect(catalog.routableProviders.includes(currentSelection(ctx, sessionId).provider)).toBe(true) + expect(catalog.groups.flatMap(group => group.models.map(model => model.id))) + .not.toContain('unlisted-but-served') + await ctx.fiber.dispose() + }) + + it('serves a session and its catalog when the stored default names a route that is gone', async () => { + const { ctx, sessionId } = await harness() + createSessionTestRemote(ctx, { + // What a Models-page removal leaves behind: the settings document still + // names the route the user last picked, and nothing serves it. + defaultModelSelection: () => ({ provider: 'deleted-gateway', model: 'deleted-model' }), + cwd: '/tmp', + }) + + const catalog = await buildModelCatalog(ctx) + // Passed through rather than repaired: matching no group is precisely what + // makes the composer seat prompt for a selection instead of naming a model + // the deployment cannot reach. + expect(currentSelection(ctx, sessionId)).toEqual({ provider: 'deleted-gateway', model: 'deleted-model' }) + expect(catalog.groups.flatMap(group => group.models.map(model => `${group.id}/${model.id}`))) + .not.toContain('deleted-gateway/deleted-model') + await ctx.fiber.dispose() + }) + + it('maps image admission failures and accepts image-capable selections', async () => { + const { ctx, agent, sessionId } = await harness() + registerTextOnly(ctx) + ctx.llm.registerAdapter(['image-capable'], new class extends CatalogAdapter { + override resolveModel(provider: string, model: string): Promise { + return Promise.resolve({ + provider, id: model, name: model, inputModalities: ['text', 'image'], + }) + } + }('Image Capable', [])) + ctx.llm.registerAdapter(['string-error'], new class extends CatalogAdapter { + override resolveModel(): Promise { + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error provider normalization is the scenario. + return Promise.reject('string selection failure') + } + }('String Error', [])) + let saveMode: 'success' | 'error' | 'remote' = 'success' + const savedRef = { + attachmentId: 'saved-image', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1, + } + ctx.provide('attachments', { + saveImages: () => { + if (saveMode === 'error') return Promise.reject(new Error('image store offline')) + if (saveMode === 'remote') { + return Promise.reject(new TypertRemoteFailure({ + code: 'fixture-rejected', message: 'fixture rejected', details: {}, + })) + } + return Promise.resolve([savedRef]) + }, + } as never) + const followup = vi.fn() + Object.assign(agent, { followup }) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'deepseek-official', model: 'deepseek-chat' }), + cwd: '/tmp', + }) + const image = { type: 'image' as const, mediaType: 'image/png' as const, data: 'AQ==' } + + expectValue(await remote.selectModel(request({ + sessionId, provider: 'text-only', model: 'plain', + }))) + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [image], + }))).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'MODEL_DOES_NOT_SUPPORT_IMAGES' } }, + }) + + expectValue(await remote.selectModel(request({ + sessionId, provider: 'image-capable', model: 'vision', + }))) + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [{ ...image, data: '' }], + }))).toMatchObject({ + ok: false, + error: { code: 'attachment-error', details: { reason: 'INVALID_IMAGE_BASE64' } }, + }) + + saveMode = 'error' + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [image], + }))).toMatchObject({ ok: false, error: { code: 'agent-busy' } }) + saveMode = 'remote' + expect(await remote.prompt(promptRequest({ + sessionId, mode: 'queue', content: [image], + }))).toMatchObject({ ok: false, error: { code: 'fixture-rejected' } }) + saveMode = 'success' + expectValue(await remote.prompt(promptRequest({ sessionId, mode: 'queue', content: [image] }))) + expect(followup).toHaveBeenCalledOnce() + + ;(agent.inbox.nextTurn as UserMessage[]).push({ + id: 'pending-image', role: 'user', source: { kind: 'user' }, + content: [{ type: 'image', attachment: savedRef }], + } as never) + expectValue(await remote.selectModel(request({ + sessionId, provider: 'deepseek-official', model: 'deepseek-chat', + }))) + expectValue(await remote.selectModel(request({ + sessionId, provider: 'image-capable', model: 'vision', + }))) + expect(await remote.selectModel(request({ + sessionId, provider: 'metadata-broken', model: 'broken', + }))).toMatchObject({ + ok: false, error: { code: 'model-unavailable', message: 'reasoning metadata offline' }, + }) + expect(await remote.selectModel(request({ + sessionId, provider: 'string-error', model: 'broken', + }))).toMatchObject({ + ok: false, + error: { code: 'model-unavailable', message: 'string selection failure' }, + }) + await ctx.fiber.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/session-presets.host.spec.ts b/packages/api/session-controller/tests/session-presets.host.spec.ts new file mode 100644 index 0000000000..10ce0e4269 --- /dev/null +++ b/packages/api/session-controller/tests/session-presets.host.spec.ts @@ -0,0 +1,167 @@ +/** Session creation and adoption rules for Agent preset identity. */ + +import { mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' +import { agentPresetProjectionDefinition, UnknownPresetError } from '@deepseek-ai/dsh-agent-presets' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import { describe, expect, it } from 'vitest' +import { createSessionTestRemote } from './test-remote.ts' + +function stubAgent(session: Session): Agent { + return { id: session.id, session, status: 'idle' } as unknown as Agent +} + +function roster(ids: readonly string[]): unknown { + const presetOf = (id: string): object => ({ + id, + trust: 'system', + path: `/presets/${id}/agent.cordis.yml`, + }) + return { + defaultId: ids[0], + resolve: (id?: string) => { + const wanted = id ?? ids[0] ?? '' + if (!ids.includes(wanted)) return Promise.reject(new UnknownPresetError(wanted, ids)) + return Promise.resolve(presetOf(wanted)) + }, + mount: (_ctx: Context, id?: string) => Promise.resolve(presetOf(id ?? ids[0] ?? '')), + } +} + +async function harness(presets?: readonly string[]) { + const cwd = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-session-preset-'))) + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + if (presets !== undefined) { + ctx.provide('agentPresets', roster(presets) as never) + } + + const factory: AgentFactory = { + async createAgent(_ownerCtx, options) { + const session = ctx.sessions.create( + options.sessionId, + options.meta === undefined ? {} : { meta: options.meta }, + ) + const agent = stubAgent(session) + const agentCtx = ctx.extend({ agent }) + ;(agent as { ctx?: Context }).ctx = agentCtx + await options.setup?.(agentCtx) + const unregister = ctx.agents.register(agent) + return { agent, dispose: () => { unregister(); return Promise.resolve() } } + }, + async resume() { + throw new Error('test harness has no persisted sessions') + }, + } + ctx.agents.setFactory(factory) + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }), + cwd, + }) + if (presets !== undefined) ctx.sessionProjections.register(agentPresetProjectionDefinition) + return { ctx, remote } +} + +describe('session.create Agent preset identity', () => { + it('records the requested preset on the Session header', async () => { + const { ctx, remote } = await harness(['standard', 'minimal']) + + const created = await remote.create({ sessionId: SessionId('s1'), agentPreset: 'minimal' }) + + expect(created.ok).toBe(true) + expect(ctx.sessions.get(SessionId('s1'))?.header.agentPreset).toBe('minimal') + }) + + it('records the roster default when the caller names no preset', async () => { + const { ctx, remote } = await harness(['standard', 'minimal']) + + await remote.create({ sessionId: SessionId('s2') }) + + expect(ctx.sessions.get(SessionId('s2'))?.header.agentPreset).toBe('standard') + }) + + it('rejects an unknown preset', async () => { + const { remote } = await harness(['standard']) + + const response = await remote.create({ sessionId: SessionId('s3'), agentPreset: 'nope' }) + + expect(response).toMatchObject({ ok: false, error: { code: 'agent-preset-not-found' } }) + }) + + it('refuses to adopt a live Session under a different preset', async () => { + const { remote } = await harness(['standard', 'minimal']) + await remote.create({ sessionId: SessionId('s4'), agentPreset: 'minimal' }) + + const response = await remote.create({ sessionId: SessionId('s4'), agentPreset: 'standard' }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'agent-preset-conflict', + details: { + sessionId: 's4', + requestedPreset: 'standard', + existingPreset: 'minimal', + }, + }, + }) + }) + + it('adopts a live Session under the preset selected in its log', async () => { + const { ctx, remote } = await harness(['standard', 'minimal']) + await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) + ctx.sessions.get(SessionId('s4b'))?.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const adopted = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'minimal' }) + const stale = await remote.create({ sessionId: SessionId('s4b'), agentPreset: 'standard' }) + + expect(adopted).toMatchObject({ ok: true, value: { agentPreset: 'minimal' } }) + expect(stale).toMatchObject({ + ok: false, + error: { details: { existingPreset: 'minimal' } }, + }) + }) + + it('adopts a live Session unchanged when the caller names no preset', async () => { + const { remote } = await harness(['standard', 'minimal']) + await remote.create({ sessionId: SessionId('s5'), agentPreset: 'minimal' }) + + await expect(remote.create({ sessionId: SessionId('s5') })) + .resolves.toMatchObject({ ok: true }) + }) + + it('leaves the header preset-less when no roster is composed', async () => { + const { ctx, remote } = await harness() + + await remote.create({ sessionId: SessionId('s6') }) + + expect(ctx.sessions.get(SessionId('s6'))?.header.agentPreset).toBeUndefined() + }) + + it('explains why a preset-less Session cannot be adopted under one', async () => { + const { remote } = await harness() + await remote.create({ sessionId: SessionId('s7') }) + + const response = await remote.create({ sessionId: SessionId('s7'), agentPreset: 'standard' }) + + expect(response).toMatchObject({ + ok: false, + error: { + code: 'agent-preset-conflict', + details: { + sessionId: 's7', + requestedPreset: 'standard', + }, + }, + }) + if (response.ok) throw new Error('unreachable') + expect('existingPreset' in response.error.details).toBe(false) + expect(response.error.message).toContain('records no agent preset') + }) +}) diff --git a/packages/api/session-controller/tests/session-projections.host.spec.ts b/packages/api/session-controller/tests/session-projections.host.spec.ts new file mode 100644 index 0000000000..fd30f66158 --- /dev/null +++ b/packages/api/session-controller/tests/session-projections.host.spec.ts @@ -0,0 +1,525 @@ +/** + * Session Controller projection paths: the history tail page's + * projections block reads the registry's watermark snapshot (asOfSeq = last + * event seq, one consistent cut); loadOlder pages never carry the block; a + * composition without the registry serves histories without it; a disposed + * registration's key leaves subsequent responses; and every unit change is + * pushed through the control stream. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { z } from 'zod' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import { agentPresetProjectionDefinition } from '@deepseek-ai/dsh-agent-presets' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import type { ProjectionDefinition } from '@deepseek-ai/dsh-session-projection' +import { SessionControlController } from '@deepseek-ai/dsh-api-session-controller/src/control.ts' +import type { SessionControlFrame, SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types' +import { createSessionTestRemote, type TestSessionRemote } from './test-remote.ts' + +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + 'test/last-user': LastUserState + 'test/internal-count': number + } + interface SessionProjectionMap { + 'test/last-user': { text: string } | null + } +} + +function request

(payload: P): P { + return payload +} + +function page( + remote: TestSessionRemote, + request: { sessionId: SessionId; throughSeq: number; beforeSeq?: number; maxMessages?: number }, +) { + return remote.page({ + address: { kind: 'session', sessionId: request.sessionId }, + throughSeq: request.throughSeq, + ...(request.beforeSeq === undefined ? {} : { beforeSeq: request.beforeSeq }), + ...(request.maxMessages === undefined ? {} : { maxMessages: request.maxMessages }), + }) +} + +/** Read and close one snapshot-first follow generation. */ +async function opening( + remote: TestSessionRemote, + sessionId: SessionId, + maxMessages?: number, +): Promise> { + const abort = new AbortController() + const iterator = remote.follow({ + address: { kind: 'session', sessionId }, + ...(maxMessages === undefined ? {} : { maxMessages }), + }, abort.signal)[Symbol.asyncIterator]() + const first = await iterator.next() + abort.abort() + await iterator.return?.() + if (first.done || first.value.type !== 'snapshot') throw new Error('follow did not open with a snapshot') + return first.value +} + +/** Whole-value unit folding the latest user/message text; null before the first. */ +type LastUserState = { text: string } | null +const lastUserUnit = () => ({ + key: 'test/last-user', + stateSchema: z.union([z.object({ text: z.string() }), z.null()]), + init: () => null, + apply: (state, event) => (event.type === 'user/message' + ? { text: (event.data.content[0] as { text?: string }).text ?? '' } + : state), + wire: { + viewSchema: z.union([z.object({ text: z.string() }), z.null()]), + view: state => state, + }, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/last-user', LastUserState> + +const internalCountUnit = () => ({ + key: 'test/internal-count', + stateSchema: z.number().int().nonnegative(), + init: () => 0, + apply: (state: number) => state + 1, + stateVersion: 1, +}) satisfies ProjectionDefinition<'test/internal-count', number> + +async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + if (withRegistry) await ctx.plugin(SessionProjectionRegistry) + const session = ctx.sessions.create(undefined, { meta: { cwd: '/workspace' } }) + // The gateway reads both the session and durable inbox baseline. + ctx.agents.register({ id: session.id, session, inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), status: 'idle', ctx } as Agent) + return { ctx, session } +} + +/** Append `count` user messages so the log has paginable message boundaries. */ +function seedMessages(session: Session, count: number): void { + for (let i = 0; i < count; i++) { + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `m${i}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + } +} + +const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + +describe('session.history projections block', () => { + it('tracks pending and used model selections across repeated request headers', async () => { + const { ctx, session } = await harness(true) + remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + const selected = { provider: 'p', model: 'next' } + session.append('model/selection', selected) + session.append('model/selection', selected) + session.append('request/header', { + header: { config: { provider: 'p', model: 'used' } }, reason: 'initial', + }) + session.append('request/header', { + header: { config: { provider: 'p', model: 'used' } }, reason: 'initial', + }) + + expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({ + lastUsed: { provider: 'p', model: 'used' }, + next: selected, + }) + + session.append('request/header', { + header: { config: selected }, reason: 'initial', + }) + expect(ctx.sessionProjections.snapshot(session).values.modelSelection).toEqual({ + lastUsed: selected, + next: selected, + }) + }) + + it('serves the unit value on the tail page with asOfSeq = last event seq', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 3) + const snapshot = await opening(remote(ctx), session.id) + const { records, projections } = snapshot + expect(projections.asOfSeq).toBe(session.seq - 1) + expect(projections.values['test/last-user']).toEqual({ text: 'm2' }) + // asOfSeq IS the window tail: the last served event carries it. + const last = records.at(-1) + expect(last?.event.seq).toBe(projections.asOfSeq) + }) + + it('returns a complete current replacement cut on each follow generation', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 2) + + const snapshot = await opening(remote(ctx), session.id) + + expect(snapshot.records.map(record => record.event.seq)).toEqual([0, 1]) + expect(snapshot.projections.asOfSeq).toBe(1) + expect(snapshot.projections.values).toEqual( + expect.objectContaining({ 'test/last-user': { text: 'm1' } }), + ) + }) + + it('projects an empty log at cursor -1', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + + const snapshot = await opening(remote(ctx), session.id) + + expect(snapshot.records).toEqual([]) + expect(snapshot.projections.asOfSeq).toBe(-1) + expect(snapshot.projections.values).toEqual( + expect.objectContaining({ 'test/last-user': null }), + ) + }) + + it('publishes the attachments imageLimits as a constant unit while both seams are composed', async () => { + const { ctx, session } = await harness(true) + const limits = { + maxImageBytes: 5 * 1024 * 1024, + maxImagesPerMessage: 20, + maxMessageImageBytes: 100 * 1024 * 1024, + maxImagePixels: 40_000_000, + maxImageDimension: 2000, + mediaTypes: ['image/png'] as const, + } + await ctx.plugin(class extends AttachmentStore { + readonly imageLimits = limits + validateImage(): Promise { return Promise.resolve() } + saveImage(): Promise { return Promise.reject(new Error('unused')) } + readImage(): Promise { return Promise.reject(new Error('unused')) } + }) + const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + seedMessages(session, 2) + const snapshot = await opening(gateway, session.id) + expect(snapshot.projections.values['imageLimits']).toEqual(limits) + // Constant unit: appending events must never broadcast an imageLimits projection. + await new Promise(resolve => setTimeout(resolve, 0)) + const abort = new AbortController() + const iterator = gateway.control(abort.signal)[Symbol.asyncIterator]() + await iterator.next() + const next = iterator.next() + seedMessages(session, 1) + await new Promise(resolve => setTimeout(resolve, 0)) + await expect(next).resolves.toMatchObject({ + done: false, + value: { type: 'projection', key: 'sessionListMetadata' }, + }) + const extra = iterator.next() + const quiet = Symbol('quiet') + expect(await Promise.race([ + extra, + new Promise(resolve => setTimeout(() => { resolve(quiet) }, 0)), + ])).toBe(quiet) + abort.abort() + await expect(extra).resolves.toEqual({ done: true, value: undefined }) + }) + + it('leaves the imageLimits key absent while no attachment service is composed', async () => { + const { ctx, session } = await harness(true) + seedMessages(session, 1) + const snapshot = await opening(remote(ctx), session.id) + expect('imageLimits' in snapshot.projections.values).toBe(false) + }) + + it('never carries the block on loadOlder pages (beforeSeq present)', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 5) + const older = await page(remote(ctx), request({ + sessionId: session.id, throughSeq: session.seq - 1, beforeSeq: 3, maxMessages: 2, + })) + expect(older.ok).toBe(true) + if (!older.ok) throw new Error('unreachable') + expect('projections' in older.value).toBe(false) + }) + + it('serves no block when the composition has no projection registry', async () => { + const { ctx, session } = await harness(false) + seedMessages(session, 2) + const response = await page(remote(ctx), request({ sessionId: session.id, throughSeq: session.seq - 1 })) + expect(response.ok).toBe(true) + if (!response.ok) throw new Error('unreachable') + expect('projections' in response.value).toBe(false) + }) + + it('never exposes a host-only unit through history, listing, or push frames', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(internalCountUnit()) + const proxy = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + const abort = new AbortController() + const iterator = proxy.control(abort.signal)[Symbol.asyncIterator]() + const baseline = await iterator.next() + if (baseline.done || baseline.value.type !== 'baseline') { + throw new Error('control stream ended before its baseline') + } + expect('test/internal-count' in (baseline.value.value.projections[session.id]?.values ?? {})) + .toBe(false) + + seedMessages(session, 1) + const changed = await iterator.next() + expect(changed).toMatchObject({ + done: false, + value: { type: 'projection', key: 'sessionListMetadata' }, + }) + abort.abort() + await iterator.return?.() + + const history = await opening(proxy, session.id) + expect('test/internal-count' in history.projections.values).toBe(false) + const listing = await proxy.list(request({})) + if (!listing.ok) throw new Error('listing failed') + const row = listing.value.items.find(item => item.sessionId === session.id) + expect('test/internal-count' in (row?.projections?.values ?? {})).toBe(false) + }) + + it('drops a disposed registration from subsequent tail pages (empty block, key absent)', async () => { + const { ctx, session } = await harness(true) + const dispose = ctx.sessionProjections.register(lastUserUnit()) + seedMessages(session, 1) + const proxy = remote(ctx) + const before = await opening(proxy, session.id) + expect(before.projections.values['test/last-user']).toEqual({ text: 'm0' }) + + dispose() + const after = await opening(proxy, session.id) + // The registry stays mounted; only the disposed key leaves while the + // gateway-owned Session-list unit remains. + expect(after.projections.asOfSeq).toBe(session.seq - 1) + expect('test/last-user' in after.projections.values).toBe(false) + expect(after.projections.values.sessionListMetadata).toEqual({ + blank: true, + lastPromptAt: session.events.at(-1)?.time, + }) + }) + + it('removes the gateway-owned Session-list unit when the gateway fiber unloads', async () => { + const { ctx, session } = await harness(true) + expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false) + const fiber = ctx.plugin(Object.assign((gatewayCtx: Context) => { + createSessionTestRemote(gatewayCtx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + }, { inject: ['sessions', 'agents', 'sessionProjections'] })) + await fiber.await() + await vi.waitFor(() => { + expect(ctx.sessionProjections.snapshot(session).values.sessionListMetadata) + .toEqual({ blank: true, lastPromptAt: null }) + }) + await fiber.dispose() + expect('sessionListMetadata' in ctx.sessionProjections.snapshot(session).values).toBe(false) + }) +}) + +describe('session.list projections column', () => { + it('serves every already-materialized wire value from the live registry without folding', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + session.append('turn/start', { turn: 1 }) + seedMessages(session, 1) + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row?.projections?.values['test/last-user']).toEqual({ text: 'm0' }) + expect(row?.projections?.values.sessionListMetadata).toEqual({ + blank: false, + lastPromptAt: session.events.at(-1)?.time, + }) + expect(row?.projections?.asOfSeq).toBe(session.seq - 1) + }) + + it('lists the latest preset selected by a blank Session instead of its creation preset', async () => { + const { ctx } = await harness(true) + const session = ctx.sessions.create(SessionId('preset-list'), { + meta: { cwd: '/workspace', agentPreset: 'standard' }, + }) + ctx.sessionProjections.register(agentPresetProjectionDefinition) + const gateway = remote(ctx) + await new Promise(resolve => setTimeout(resolve, 0)) + session.append('agent-preset/selected', { agentPreset: 'minimal' }) + + const response = await gateway.list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row?.projections?.values.agentPreset).toBe('minimal') + }) + + it('omits an unmaterialized live projection instead of folding history for listing', async () => { + const { ctx, session } = await harness(true) + seedMessages(session, 1) + const unit = lastUserUnit() + const apply = vi.fn(unit.apply) + ctx.sessionProjections.register({ ...unit, apply }) + + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect('test/last-user' in (row?.projections?.values ?? {})).toBe(false) + expect(apply).not.toHaveBeenCalled() + }) + + it('omits the column entirely when no registry is mounted', async () => { + const { ctx, session } = await harness(false) + seedMessages(session, 1) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) + + it('serves every available cold projection hint from the cache with zero log loads', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-listing') + const load = () => { throw new Error('list must not load event logs') } + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load, + inspect: load, + readFrom: load, + } as never) + ctx.provide('sessionProjectionCache', { + // The carrier hands the listed header through as the identity witness. + cachedSnapshot: (meta: { id: unknown; createdAt: number }) => + (meta.id === coldId && meta.createdAt === 5 + ? { + asOfSeq: 7, + values: { + 'test/last-user': { text: 'cached' }, + sessionListMetadata: { blank: false, lastPromptAt: 6 }, + title: 'Cached title', + }, + } + : undefined), + } as never) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === coldId) + expect(row?.running).toBe(false) + expect(row?.projections).toEqual({ + asOfSeq: 7, + values: { + 'test/last-user': { text: 'cached' }, + sessionListMetadata: { blank: false, lastPromptAt: 6 }, + title: 'Cached title', + }, + }) + }) + + it('cold rows without a cache plugin (or without a stored row) just lack the column', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-uncached') + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + } as never) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === coldId) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) + + it('a throwing column read degrades that row, never the listing', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register({ + ...lastUserUnit(), + wire: { + viewSchema: z.union([z.object({ text: z.string() }), z.null()]), + view: () => { throw new Error('unit exploded') }, + }, + }) + seedMessages(session, 1) + const response = await remote(ctx).list(request({})) + if (!response.ok) throw new Error('unreachable') + const row = response.value.items.find(item => item.sessionId === session.id) + expect(row).toBeDefined() + expect(row !== undefined && 'projections' in row).toBe(false) + }) +}) + +describe('Session control projection frames', () => { + /** Drain frames until `count` projection replacements arrive. */ + async function collect( + iterable: AsyncIterable, + count: number, + abort: AbortController, + ): Promise { + const frames: SessionControlFrame[] = [] + for await (const frame of iterable) { + frames.push(frame) + if (frames.filter(candidate => candidate.type === 'projection').length >= count) abort.abort() + } + return frames + } + + it('broadcasts a frame per changed unit with the causing seq, and none for same-reference applies', async () => { + const { ctx, session } = await harness(true) + ctx.sessionProjections.register(lastUserUnit()) + const proxy = remote(ctx) + // The controller's onChanged subscription lives in an inject child whose + // fiber activates asynchronously; yield until it lands before appending. + await new Promise(resolve => setTimeout(resolve, 0)) + const abort = new AbortController() + const stream = proxy.control(abort.signal) + const collected = collect(stream, 5, abort) + + const now = vi.spyOn(Date, 'now').mockReturnValue(100) + seedMessages(session, 1) + now.mockReturnValue(200) + session.append('turn/start', { turn: 1 }) + now.mockReturnValue(300) + seedMessages(session, 1) + now.mockRestore() + + const frames = await collected + const pushes = frames.filter( + (f): f is Extract => + f.type === 'projection' && f.key === 'test/last-user', + ) + expect(pushes).toEqual([ + { type: 'projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 0 }, + { type: 'projection', sessionId: session.id, key: 'test/last-user', value: { text: 'm0' }, seq: 2 }, + ]) + expect(frames.filter( + (f): f is Extract => + f.type === 'projection' && f.key === 'sessionListMetadata', + )).toEqual([ + { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: true, lastPromptAt: 100 }, seq: 0 }, + { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 100 }, seq: 1 }, + { type: 'projection', sessionId: session.id, key: 'sessionListMetadata', value: { blank: false, lastPromptAt: 300 }, seq: 2 }, + ]) + // Frame seq aligns with the tail block's asOfSeq vocabulary (higher-seq-wins compatible). + const tail = await opening(proxy, session.id) + expect(tail.projections.asOfSeq).toBe(pushes.at(-1)?.seq) + }) + + it('emits no projection frames when the composition has no registry', async () => { + const { ctx, session } = await harness(false) + const control = new SessionControlController(ctx) + const abort = new AbortController() + const iterator = control.control(abort.signal)[Symbol.asyncIterator]() + const baseline = await iterator.next() + const next = iterator.next() + seedMessages(session, 2) + await new Promise(resolve => setTimeout(resolve, 0)) + abort.abort() + if (baseline.done) throw new Error('Control stream ended before its baseline') + expect(baseline.value.type).toBe('baseline') + await expect(next).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/session-controller/tests/session-rename.host.spec.ts b/packages/api/session-controller/tests/session-rename.host.spec.ts new file mode 100644 index 0000000000..b73e72d0e3 --- /dev/null +++ b/packages/api/session-controller/tests/session-rename.host.spec.ts @@ -0,0 +1,124 @@ +/** + * Session Controller rename delegation through the composed SessionTitleService. The + * agent factory is a structural stub whose createAgent forwards seed/meta into + * the real SessionStore, and whose resume never runs (every source here is + * already attached). Cold-session resolution is the shared `agentFor` path — + * remote-proxy-cold.spec.ts owns the resume evidence for every unary that rides + * it, rename included. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SessionStore from '@deepseek-ai/dsh-session' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionTitleService from '@deepseek-ai/dsh-session-title' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' +import { createSessionTestRemote } from './test-remote.ts' + +const sid = (id: string): SessionId => id as SessionId + +function request

(payload: P): P { + return payload +} + +async function composed(withTitles = true): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + if (withTitles) { + await ctx.plugin(SessionTitleService, { fallbackMaxWords: 5, fallbackMaxBytes: 40, maxTitleBytes: 40 }) + } + // Store-backed structural factory: create builds the session with the + // forwarded seed/meta (the store validates the balanced prefix) and + // registers an idle agent stub over it. + ctx.agents.setFactory({ + createAgent: (ownerCtx: Context, options: CreateAgentOptions): Promise => { + const session = ctx.sessions.create(options.sessionId, { + ...options.seed === undefined ? {} : { seed: [...options.seed] }, + ...options.meta === undefined ? {} : { meta: options.meta }, + }) + const agent = { id: session.id, session, status: 'idle', ctx: ownerCtx } as Agent + ctx.agents.register(agent) + return Promise.resolve({ agent, dispose: () => Promise.resolve() }) + }, + resume: () => Promise.reject(new Error('resume must not run: every source is attached')), + }) + return ctx +} + +/** Register one live agent whose log holds `turns` completed turns. */ +function liveAgent(ctx: Context, id: string, turns: number): Session { + const session = ctx.sessions.create(sid(id), { meta: { cwd: '/proj' } }) + for (let turn = 1; turn <= turns; turn++) { + session.append('turn/start', { turn }) + session.append('user/message', createUserMessage({ + content: [{ type: 'text', text: `prompt ${String(turn)}` }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + session.append('turn/end', { turn, reason: { kind: 'completed' } }) + } + ctx.agents.register({ id: session.id, session, status: 'idle', ctx } as Agent) + return session +} + +const remote = (ctx: Context) => createSessionTestRemote(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + +describe('sessions.rename', () => { + it('accepts through the composed title service: normalized user-source event, echoed seq', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-rename', 1) + + const renamed = await remote(ctx).rename(request({ sessionId: source.id, title: ' new name ' })) + expect(renamed.ok).toBe(true) + if (!renamed.ok) return + expect(renamed.value.title).toBe('new name') + const event = source.events.findLast(item => item.type === 'session/title') + expect(event?.seq).toBe(renamed.value.seq) + expect(event?.data).toMatchObject({ title: 'new name', source: { kind: 'user' } }) + }) + + it('maps only an empty-normalizing title to title-invalid, with a presentable message', async () => { + const ctx = await composed() + const source = liveAgent(ctx, 'session-rename-bad', 1) + + // U+200B passes a client-side trim gate but normalizes to empty host-side. + const response = await remote(ctx).rename(request({ sessionId: source.id, title: ' ​ ' })) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error).toMatchObject({ + code: 'title-invalid', + details: { sessionId: source.id }, + }) + // The message renders verbatim in the rename dialog's alert. + expect(response.error.message).toBe('session title must contain visible characters') + } + }) + + it('maps a non-validation rename failure (stale session object) to internal, not title-invalid', async () => { + const ctx = await composed() + // The registered agent holds a session object from another store: the + // title service's liveness check throws a plain Error, which must not + // read as the user's fault. + const foreign = await composed(false) + const stale = liveAgent(foreign, 'session-rename-stale', 1) + ctx.agents.register({ id: stale.id, session: stale, status: 'idle', ctx } as Agent) + + const response = await remote(ctx).rename(request({ sessionId: stale.id, title: 'name' })) + expect(response.ok).toBe(false) + if (!response.ok) expect(response.error.code).toBe('internal') + }) + + it('answers internal when the composition mounts no session-title service', async () => { + const ctx = await composed(false) + const source = liveAgent(ctx, 'session-no-titles', 1) + + const response = await remote(ctx).rename(request({ sessionId: source.id, title: 'name' })) + expect(response.ok).toBe(false) + if (!response.ok) { + expect(response.error.code).toBe('internal') + expect(response.error.message).toMatch(/mounts no session-title service/) + } + }) +}) diff --git a/packages/api/session-controller/tests/session-search.host.spec.ts b/packages/api/session-controller/tests/session-search.host.spec.ts new file mode 100644 index 0000000000..49bc900566 --- /dev/null +++ b/packages/api/session-controller/tests/session-search.host.spec.ts @@ -0,0 +1,894 @@ +/** + * Session Controller search projection: list-equivalent visibility, fixed message + * filters and result bound, cancellation mapping, and unavailable/failure + * behavior. + */ + +import { describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry from '@deepseek-ai/dsh-agent' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SessionStore from '@deepseek-ai/dsh-session' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import { + SessionQueryEngine, + SessionQueryError, + type SessionSearchHit, + type SessionSearchRequest, +} from '@deepseek-ai/dsh-session-query' +import { createSessionTestRemote } from './test-remote.ts' +import { ApiSessionList } from '../src/list.ts' + +const sid = (value: string): SessionId => value as SessionId +const defaults = { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' } + +function request(query: string): { query: string } { + return { query } +} + +function header(id: string, cwd: string | null = '/project'): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 100, + ...(cwd === null ? {} : { cwd }), + } +} + +function hit(id: string, index = 0): SessionSearchHit { + const session = header(id) + return { + header: session, + live: true, + persisted: false, + bestMatch: { + sessionId: session.id, + seq: index, + type: 'user/message', + time: 200 + index, + surface: 'current', + snippet: `match ${index}`, + }, + } +} + +async function baseContext(): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(AgentRegistry) + return ctx +} + +/** Real query core with a programmable full-text provider for Host search tests. */ +class SearchSessionQuery extends SessionQueryEngine { + constructor( + ctx: Context, + private readonly search: ( + ...args: Parameters + ) => Promise, + ) { + super(ctx) + } + + override searchSessions( + ...args: Parameters + ): ReturnType { + return this.search(...args) as ReturnType + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} + +function installSearchQuery( + ctx: Context, + searchSessions: ( + ...args: Parameters + ) => Promise, +): void { + new SearchSessionQuery(ctx, searchSessions) +} + +describe('session.search', () => { + it('rejects search when the query service is absent', async () => { + const ctx = await baseContext() + const list = new ApiSessionList(ctx, 0) + + await expect(list.search('query', new AbortController().signal)).rejects.toMatchObject({ + failure: { code: 'internal' }, + }) + await ctx.fiber.dispose() + }) + + it('searches only list-visible ids and current conversation-message events', async () => { + const ctx = await baseContext() + const live = ctx.sessions.create(sid('live'), { meta: header('live', '/live') }) + live.append('user/message', createUserMessage({ + content: [{ type: 'text', text: 'live text' }], + source: { kind: 'user' }, + }), { surfaceOp: 'append' }) + const cold = header('cold', '/cold') + const legacy = header('legacy', null) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve([cold, legacy]), + locate: () => undefined, + } as never) + + const searchSessions = vi.fn(( + _request: SessionSearchRequest, + _exec?: { signal?: AbortSignal }, + ) => Promise.resolve({ + items: [ + { + header: legacy, + live: false, + persisted: true, + bestMatch: { + sessionId: legacy.id, + seq: 3, + type: 'user/message' as const, + time: 190, + surface: 'current' as const, + snippet: 'must remain hidden', + }, + }, + { + header: cold, + live: false, + persisted: true, + bestMatch: { + sessionId: cold.id, + seq: 4, + type: 'assistant/message' as const, + time: 200, + surface: 'current' as const, + snippet: 'the matching answer', + }, + }, + ], + })) + installSearchQuery(ctx, searchSessions) + const remote = createSessionTestRemote(ctx, defaults) + const signal = new AbortController().signal + + const response = await remote.search(request(' matching answer '), signal) + + expect(response).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'cold', snippet: 'the matching answer' }], + hasMore: false, + }, + }) + expect(searchSessions).toHaveBeenCalledOnce() + const [query, exec] = searchSessions.mock.calls[0] as unknown as [ + SessionSearchRequest, + { signal: AbortSignal }, + ] + expect(query).toEqual({ + query: 'matching answer', + eventFilters: [ + { + kind: 'type', + values: ['user/message', 'assistant/message'], + }, + { kind: 'surface', values: ['current'] }, + ], + limit: 20, + }) + expect(exec.signal).toBe(signal) + }) + + it('rejects invalid wire queries before invoking the search provider', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn() + installSearchQuery(ctx, searchSessions) + const remote = createSessionTestRemote(ctx, defaults) + + for (const query of ['', ' ', 'contains\0nul', 'x'.repeat(501)]) { + await expect(remote.search(request(query), new AbortController().signal)) + .resolves.toMatchObject({ ok: false, error: { code: 'bad-request' } }) + } + expect(searchSessions).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('returns an empty page without invoking the index when no session is visible', async () => { + const ctx = await baseContext() + const searchSessions = vi.fn() + installSearchQuery(ctx, searchSessions) + const remote = createSessionTestRemote(ctx, defaults) + + const response = await remote.search( + request('anything'), + new AbortController().signal, + ) + + expect(response).toEqual({ + ok: true, + value: { items: [], hasMore: false }, + }) + expect(searchSessions).not.toHaveBeenCalled() + }) + + it('rejects snippets whose recorded provider violates the Host filters', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + const withBestMatch = ( + index: number, + bestMatch: Partial, + ): SessionSearchHit => { + const base = hit('visible', index) + return { ...base, bestMatch: { ...base.bestMatch, ...bestMatch } } + } + installSearchQuery(ctx, () => Promise.resolve({ + items: [ + withBestMatch(0, { sessionId: sid('hidden') }), + withBestMatch(1, { surface: 'shadowed' }), + withBestMatch(2, { type: 'tool/result' }), + withBestMatch(3, { type: 'user/message', snippet: 'allowed snippet' }), + ], + })) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('match'), + new AbortController().signal, + ) + + expect(response).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible', snippet: 'allowed snippet' }], + hasMore: false, + }, + }) + }) + + it('pages the globally ranked stream until the 20-item Host boundary is known', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 22 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ + items: [hit('hidden-ranked-first'), ...items.slice(0, 19)], + nextCursor: 'page-2', + }) + .mockResolvedValueOnce({ items: items.slice(19) }) + installSearchQuery(ctx, searchSessions) + const response = await createSessionTestRemote(ctx, defaults).search( + request('match'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: true, + value: { hasMore: true }, + }) + if (!response.ok) throw new Error('unreachable') + expect(response.value.items).toHaveLength(20) + expect(response.value.items.at(-1)?.sessionId).toBe('visible-19') + expect(searchSessions).toHaveBeenCalledTimes(2) + expect(searchSessions.mock.calls[1]?.[0]).toMatchObject({ cursor: 'page-2' }) + }) + + it('learns a provider maxLimit of 10 and collects the 20-item result plus lookahead', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const invalidLimit = new SessionQueryError( + 'provider accepts at most 10 items', + 'SESSION_QUERY_INVALID_LIMIT', + ) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + const limit = providerRequest.limit + if (limit === undefined) throw new Error('Host search must request an explicit provider limit') + if (limit > 10) return Promise.reject(invalidLimit) + const offset = providerRequest.cursor === undefined + ? 0 + : Number.parseInt(providerRequest.cursor.slice('offset-'.length), 10) + const end = Math.min(items.length, offset + limit) + return Promise.resolve({ + items: items.slice(offset, end), + ...end < items.length ? { nextCursor: `offset-${end}` } : {}, + }) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('adaptive-page-limit'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: true, + value: { hasMore: true }, + }) + if (!response.ok) throw new Error('unreachable') + expect(response.value.items.map(item => item.sessionId)) + .toEqual(items.slice(0, 20).map(item => item.header.id)) + expect(searchSessions.mock.calls.map(([providerRequest]) => ({ + limit: providerRequest.limit, + cursor: providerRequest.cursor, + }))).toEqual([ + { limit: 20, cursor: undefined }, + { limit: 10, cursor: undefined }, + { limit: 10, cursor: 'offset-10' }, + { limit: 10, cursor: 'offset-20' }, + ]) + }) + + it('counts a page-limit probe inside the 100-call budget', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const invalidLimit = new SessionQueryError( + 'provider accepts at most 10 items', + 'SESSION_QUERY_INVALID_LIMIT', + ) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + if (searchSessions.mock.calls.length === 1) { + expect(providerRequest).toMatchObject({ limit: 20 }) + return Promise.reject(invalidLimit) + } + expect(providerRequest.limit).toBe(10) + return Promise.resolve({ + items: [], + nextCursor: `page-${searchSessions.mock.calls.length}`, + }) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('endless-pages'), + new AbortController().signal, + ) + + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('100-call work budget') + expect(searchSessions).toHaveBeenCalledTimes(100) + }) + + it('restarts a stale continuation with its learned limit and original visibility snapshot', async () => { + const ctx = await baseContext() + const oldOnly = hit('old-only', 0) + const shared = hit('shared', 1) + const freshFirst = hit('fresh-first', 2) + const freshLast = hit('fresh-last', 3) + for (const item of [oldOnly, shared, freshFirst, freshLast]) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const late = hit('late-visible', 4) + const stale = new SessionQueryError( + 'provider generation changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + const invalidLimit = new SessionQueryError( + 'provider accepts at most 10 items', + 'SESSION_QUERY_INVALID_LIMIT', + ) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + switch (searchSessions.mock.calls.length) { + case 1: + expect(providerRequest).toMatchObject({ limit: 20 }) + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.reject(invalidLimit) + case 2: + expect(providerRequest).toMatchObject({ limit: 10 }) + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.resolve({ + items: [oldOnly, shared], + nextCursor: 'old-cursor', + }) + case 3: + expect(providerRequest).toMatchObject({ limit: 10 }) + expect(providerRequest.cursor).toBe('old-cursor') + ctx.sessions.create(late.header.id, { meta: late.header }) + return Promise.reject(stale) + case 4: + expect(providerRequest).toMatchObject({ limit: 10 }) + expect(providerRequest).not.toHaveProperty('cursor') + return Promise.resolve({ + items: [freshFirst, shared], + nextCursor: 'old-cursor', + }) + case 5: + expect(providerRequest).toMatchObject({ limit: 10 }) + expect(providerRequest.cursor).toBe('old-cursor') + return Promise.resolve({ items: [freshLast, late] }) + default: + return Promise.reject(new Error('unexpected provider call')) + } + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('stale-restart'), + new AbortController().signal, + ) + + expect(response).toEqual({ + ok: true, + value: { + items: [ + { sessionId: 'fresh-first', snippet: 'match 2' }, + { sessionId: 'shared', snippet: 'match 1' }, + { sessionId: 'fresh-last', snippet: 'match 3' }, + ], + hasMore: false, + }, + }) + expect(searchSessions).toHaveBeenCalledTimes(5) + }) + + it('counts continuous stale restarts against the 100-call budget', async () => { + const ctx = await baseContext() + const partial = hit('partial') + ctx.sessions.create(partial.header.id, { meta: partial.header }) + const stale = new SessionQueryError( + 'provider generation changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + if (searchSessions.mock.calls.length > 100) { + return Promise.reject(new Error('provider was called after the shared budget')) + } + if (providerRequest.cursor !== undefined) return Promise.reject(stale) + return Promise.resolve({ + items: [partial], + nextCursor: `cursor-${searchSessions.mock.calls.length}`, + }) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('stale-churn'), + new AbortController().signal, + ) + + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error.code).toBe('internal') + expect(response.error.message).toContain('100-call work budget') + expect(response).not.toHaveProperty('value') + expect(searchSessions).toHaveBeenCalledTimes(100) + }) + + it('gives abort priority over a coincident stale continuation failure', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const controller = new AbortController() + const stale = new SessionQueryError( + 'provider generation changed', + 'SESSION_QUERY_STALE_CURSOR', + ) + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'stale-cursor' }) + .mockImplementationOnce(() => { + controller.abort() + return Promise.reject(stale) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('abort-stale'), + controller.signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + + it('does not retry a stale first-page failure', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn(() => Promise.reject(new SessionQueryError( + 'provider generation changed before paging', + 'SESSION_QUERY_STALE_CURSOR', + ))) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('first-page-stale'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(response).not.toHaveProperty('value') + expect(searchSessions).toHaveBeenCalledOnce() + }) + + it('does not adapt an invalid-limit continuation failure', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'page-2' }) + .mockRejectedValueOnce(new SessionQueryError( + 'continuation limit is invalid', + 'SESSION_QUERY_INVALID_LIMIT', + )) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('continuation-invalid-limit'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(searchSessions).toHaveBeenCalledTimes(2) + expect(searchSessions.mock.calls.map(([providerRequest]) => ( + providerRequest as SessionSearchRequest + ).limit)) + .toEqual([20, 20]) + }) + + it('stops page-limit adaptation at one item', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => Promise.reject( + new SessionQueryError( + `provider rejects ${providerRequest.limit}`, + 'SESSION_QUERY_INVALID_LIMIT', + ), + )) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('minimum-page-limit'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(searchSessions.mock.calls.map(([providerRequest]) => providerRequest.limit)) + .toEqual([20, 10, 5, 2, 1]) + }) + + it('gives abort priority over a coincident invalid first-page limit', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const controller = new AbortController() + const searchSessions = vi.fn(() => { + controller.abort() + return Promise.reject(new SessionQueryError( + 'provider rejects 20', + 'SESSION_QUERY_INVALID_LIMIT', + )) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('abort-invalid-limit'), + controller.signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(searchSessions).toHaveBeenCalledOnce() + }) + + it('rejects an oversized provider page', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const oversized = Array.from({ length: 21 }, (_, index) => hit(`oversized-${index}`)) + const searchSessions = vi.fn(() => Promise.resolve({ items: oversized })) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('oversized-page'), + new AbortController().signal, + ) + + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('returned 21 items; maximum is 20') + }) + + it('uses the learned provider limit for the overproduction guard', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const oversized = Array.from({ length: 11 }, (_, index) => hit(`oversized-${index}`)) + const searchSessions = vi.fn((providerRequest: SessionSearchRequest) => { + if (providerRequest.limit === 20) { + return Promise.reject(new SessionQueryError( + 'provider accepts at most 10 items', + 'SESSION_QUERY_INVALID_LIMIT', + )) + } + return Promise.resolve({ items: oversized }) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('adapted-oversized-page'), + new AbortController().signal, + ) + + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('returned 11 items; maximum is 10') + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + + it('bounds provider snippets to 240 Unicode code points without splitting astral text', async () => { + const ctx = await baseContext() + const visible = hit('visible') + ctx.sessions.create(visible.header.id, { meta: visible.header }) + const expected = `${'x'.repeat(239)}😀` + const overlong = { + ...visible, + bestMatch: { + ...visible.bestMatch, + snippet: `${expected}${'y'.repeat(10_000)}`, + }, + } + installSearchQuery(ctx, () => Promise.resolve({ items: [overlong] })) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('bounded-snippet'), + new AbortController().signal, + ) + + expect(response).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'visible', snippet: expected }], + hasMore: false, + }, + }) + }) + + it('fails closed when the provider repeats a continuation cursor', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) + .mockResolvedValueOnce({ items: [], nextCursor: 'repeated' }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('repeated-cursor'), + new AbortController().signal, + ) + + expect(response.ok).toBe(false) + if (response.ok) throw new Error('unreachable') + expect(response.error).toMatchObject({ code: 'internal' }) + expect(response.error.message).toContain('repeated a continuation cursor') + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + + it('validates a repeated cursor before accepting the authorized lookahead', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'repeated' }) + .mockResolvedValueOnce({ items: items.slice(20), nextCursor: 'repeated' }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('repeated-lookahead-cursor'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'internal' }, + }) + expect(response).not.toHaveProperty('value') + if (response.ok) throw new Error('unreachable') + expect(response.error.message).toContain('repeated a continuation cursor') + expect(searchSessions).toHaveBeenCalledTimes(2) + }) + + it('does not count duplicate session ids toward the result or lookahead boundary', async () => { + const ctx = await baseContext() + const items = Array.from({ length: 21 }, (_, index) => hit(`visible-${index}`, index)) + for (const item of items) { + ctx.sessions.create(item.header.id, { meta: item.header }) + } + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-2' }) + .mockResolvedValueOnce({ items: items.slice(0, 20), nextCursor: 'page-3' }) + .mockResolvedValueOnce({ items: items.slice(20) }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('duplicate-pages'), + new AbortController().signal, + ) + + expect(response).toMatchObject({ + ok: true, + value: { hasMore: true }, + }) + if (!response.ok) throw new Error('unreachable') + expect(response.value.items.map(item => item.sessionId)).toEqual( + items.slice(0, 20).map(item => item.header.id), + ) + expect(searchSessions).toHaveBeenCalledTimes(3) + }) + + it('cancels on a continuation page and passes the carrier signal to both calls', async () => { + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const controller = new AbortController() + const searchSessions = vi.fn() + .mockResolvedValueOnce({ items: [], nextCursor: 'page-2' }) + .mockImplementationOnce(() => { + controller.abort() + return Promise.resolve({ items: [] }) + }) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('cancel-continuation'), + controller.signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(searchSessions).toHaveBeenCalledTimes(2) + for (const call of searchSessions.mock.calls) { + expect(call[1]).toEqual({ signal: controller.signal }) + } + }) + + it('keeps visibility sets above SQLite variable limits out of provider bindings', async () => { + const ctx = await baseContext() + const cold = Array.from( + { length: 32_751 }, + (_, index) => header(`cold-${index}`, `/cold-${index}`), + ) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve(cold), + locate: () => undefined, + } as never) + const searchSessions = vi.fn((_request: SessionSearchRequest) => Promise.resolve({ + items: [hit('cold-32750')], + })) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('large corpus'), + new AbortController().signal, + ) + + expect(response).toEqual({ + ok: true, + value: { + items: [{ sessionId: 'cold-32750', snippet: 'match 0' }], + hasMore: false, + }, + }) + expect(searchSessions).toHaveBeenCalledOnce() + expect(searchSessions.mock.calls[0]?.[0]).not.toHaveProperty('sessionFilters') + }) + + it('propagates cancellation through the lightweight visibility listing', async () => { + const ctx = await baseContext() + const controller = new AbortController() + const cold = Array.from({ length: 32 }, (_, index) => header(`cold-${index}`, `/cold-${index}`)) + const list = vi.fn((signal?: AbortSignal) => { + expect(signal).toBe(controller.signal) + controller.abort() + return Promise.resolve(cold) + }) + let locateCalls = 0 + ctx.provide('sessionPersistence', { + list, + locate: () => { + locateCalls++ + return undefined + }, + } as never) + const searchSessions = vi.fn() + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('cancel-during-visibility'), + controller.signal, + ) + + expect(response).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + expect(list).toHaveBeenCalledOnce() + expect(locateCalls).toBe(0) + expect(searchSessions).not.toHaveBeenCalled() + }) + + it('does not stat or locate cold artifacts while collecting search visibility', async () => { + const ctx = await baseContext() + const cold = Array.from({ length: 16 }, (_, index) => header(`cold-${index}`, `/cold-${index}`)) + const locate = vi.fn((meta: SessionHeader) => ({ kind: 'jsonl', path: `/logs/${meta.id}.jsonl` })) + ctx.provide('sessionPersistence', { + list: () => Promise.resolve(cold), + locate, + } as never) + const searchSessions = vi.fn(() => Promise.resolve({ items: [] })) + installSearchQuery(ctx, searchSessions) + + const response = await createSessionTestRemote(ctx, defaults).search( + request('header-only-visibility'), + new AbortController().signal, + ) + expect(response).toMatchObject({ + ok: true, + value: { items: [], hasMore: false }, + }) + expect(locate).not.toHaveBeenCalled() + expect(searchSessions).toHaveBeenCalledOnce() + }) + + it('maps preflight cancellation, query cancellation, and provider failure', async () => { + const missingCtx = await baseContext() + missingCtx.sessions.create(sid('visible'), { meta: header('visible') }) + const missingApi = createSessionTestRemote(missingCtx, defaults) + const preAborted = new AbortController() + preAborted.abort() + const cancelledBeforeLookup = await missingApi.search( + request('cancel-before-lookup'), + preAborted.signal, + ) + expect(cancelledBeforeLookup).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + + const ctx = await baseContext() + ctx.sessions.create(sid('visible'), { meta: header('visible') }) + const aborted = new SessionQueryError('provider stopped', 'SESSION_QUERY_ABORTED') + const searchSessions = vi.fn() + .mockRejectedValueOnce(aborted) + .mockRejectedValueOnce(new Error('database unavailable')) + installSearchQuery(ctx, searchSessions) + const remote = createSessionTestRemote(ctx, defaults) + + const cancelled = await remote.search( + request('first'), + new AbortController().signal, + ) + expect(cancelled).toMatchObject({ + ok: false, + error: { code: 'cancelled' }, + }) + + const failed = await remote.search( + request('second'), + new AbortController().signal, + ) + expect(failed.ok).toBe(false) + if (failed.ok) throw new Error('unreachable') + expect(failed.error.code).toBe('internal') + expect(failed.error.message).toContain('database unavailable') + }) +}) diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts new file mode 100644 index 0000000000..150e6813d1 --- /dev/null +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -0,0 +1,740 @@ +/** Session object lifecycle, event-window transport, commands, and resync behavior. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RemoteStreamError } from '@deepseek-ai/dsh-api-gateway/client' +import type { SessionEvent } from '@deepseek-ai/dsh-session/types' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { Session, type SessionOptions } from '../src/client/sessions/session.ts' +import { FakeApiClient, deferred, err, fakeRemote, ok, remoteErr } from './fake-api.client.ts' +import { entries, ev, historyValue, plainTurn } from './event-script.client.ts' + +const SID = 'fk-s1' as SessionId +const PARENT = 'fk-parent' as SessionId + +afterEach(() => { + vi.unstubAllGlobals() +}) + +function makeSession( + api = new FakeApiClient(), + options: SessionOptions = {}, +): { api: FakeApiClient; session: Session } { + return { api, session: new Session(SID, fakeRemote(api), options) } +} + +function follow( + api: FakeApiClient, + event: SessionEvent, +): Promise { + return api.pushFollow(SID, { + type: 'event', + event: event as never, + }) +} + +function windowEntries(session: Session) { + return session.eventSource.getSnapshot().entries +} + +function eventSeqs(session: Session): number[] { + return windowEntries(session).map(entry => entry.event.seq) +} + +function histResponse(events: SessionEvent[], hasMore = false) { + return Promise.resolve(ok(historyValue(events, hasMore))) +} + +describe('Session open', () => { + it('keeps a bare Session blank until an authoritative lifecycle signal arrives', () => { + const { session } = makeSession() + expect(session.getSnapshot()).toMatchObject({ blank: true, promptAttempted: false, running: false }) + + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ blank: false, running: true }) + }) + + it('installs the tail page: cold → loading → open with window and nodes in place', async () => { + const { api, session } = makeSession() + const page = plainTurn(10, 3, '问', '答') + api.onHistory = () => histResponse(page, true) + expect(session.getSnapshot().openState).toBe('cold') + const opening = session.open() + expect(session.getSnapshot().openState).toBe('loading') + await opening + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') + expect(snapshot.hasMore).toBe(true) + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15]) + expect(session.eventSource.getSnapshot().change).toMatchObject({ kind: 'replace' }) + }) + + it('is idempotent: concurrent opens share one follow, reopening when open is a no-op', async () => { + const { api, session } = makeSession() + await Promise.all([session.open(), session.open()]) + await session.open() + expect(api.callsOf('session.follow')).toHaveLength(1) + expect(api.callsOf('session.history')).toEqual([]) + }) + + it('lands an error result in openState=error with the RpcError kept', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.resolve(err({ code: 'session-not-found', message: 'gone', details: { sessionId: SID } })) + await session.open() + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('error') + expect(snapshot.openError?.code).toBe('session-not-found') + }) + + it('folds a transport throw into openState=error / internal', async () => { + const { api, session } = makeSession() + api.onHistory = () => Promise.reject(new Error('socket died')) + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'socket died' }) + }) + + it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { + const { api, session } = makeSession() + const gate = deferred>>() + api.onHistory = () => gate.promise + const opening = session.open() + // Three live frames land while the opening snapshot is pending; seq 15 overlaps its tail. + const page = plainTurn(10, 0, '早', '安') + const deliveries = [ + follow(api, ev.turnStart(15, 1)), + follow(api, ev.user(16, '插进来的')), + ] + gate.resolve(ok({ + records: entries(page) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await Promise.all([opening, ...deliveries]) + const seqs = eventSeqs(session) + // Overlapping seq-15 frame (== page tail turn/end) was dropped; 16 appended once. + expect(seqs).toEqual([10, 11, 12, 13, 14, 15, 16]) + }) +}) + + +describe('live event path', () => { + async function opened(events: SessionEvent[] = plainTurn(0, 0, 'a', 'b')) { + const { api, session } = makeSession() + api.onHistory = () => histResponse(events) + await session.open() + return { api, session } + } + + it('drops replayed frames at or below the window tail', async () => { + const { api, session } = await opened() + const before = session.eventSource.getSnapshot() + await follow(api, ev.user(3, '重放')) + expect(session.eventSource.getSnapshot()).toBe(before) + }) + + it('keeps the authoritative host blank bit across unrelated log events', async () => { + const { api, session } = await opened([]) + session.handleBlank(true) + await Promise.all([ + follow(api, ev.commandRun(0, 'cmd-perm', 'permission', ' danger-full-access')), + follow(api, ev.commandDone(1, 'cmd-perm', 'success', 'preset danger-full-access')), + ]) + const snapshot = session.getSnapshot() + expect(eventSeqs(session)).toEqual([0, 1]) + expect(snapshot.blank).toBe(true) + }) + + 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')] + api.onHistory = () => histResponse(repaired) + // seq 9 with tail 5 → gap; the event detours to the buffer and one history refetch fires. + await follow(api, ev.assistant(9, 1, 'd')) + await vi.waitFor(() => { + expect(api.callsOf('session.history')).toHaveLength(1) + }) + await vi.waitFor(() => { + expect(eventSeqs(session)).toEqual( + repaired.filter(event => event.seq <= 9).map(event => event.seq), + ) + }) + }) +}) + +describe('paging', () => { + it('prepends an older page and keeps seq continuity', async () => { + const older = plainTurn(0, 0, '旧问', '旧答') + const newer = plainTurn(6, 1, '新问', '新答') + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(newer, true) + : histResponse(older, false) + await session.open() + await session.loadOlder() + const snapshot = session.getSnapshot() + expect(api.callsOf('session.follow')).toHaveLength(1) + expect(api.callsOf('session.history')).toMatchObject([ + { sessionId: SID, throughSeq: 11, beforeSeq: 6 }, + ]) + expect(snapshot.hasMore).toBe(false) + expect(eventSeqs(session)).toEqual([...older, ...newer].map(event => event.seq)) + }) + + it('installs a page without interpreting business replacement metadata', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse([ + ev.compactSummary(80, '窗外范围的摘要', 3, 40), + ev.compactCheckpoint(81, 80, 3, 40), + ev.user(82, '压缩后的新问题'), + ], true) + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.open() + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') + expect(eventSeqs(session)).toEqual([80, 81, 82]) + expect(errorSpy).not.toHaveBeenCalled() + } finally { + errorSpy.mockRestore() + } + }) + + it('drops a discontinuous older page fail-soft (window unchanged, hasMore cleared)', async () => { + const { api, session } = makeSession() + api.onHistory = payload => payload.beforeSeq === undefined + ? histResponse(plainTurn(10, 1, '新', '页'), true) + : histResponse(plainTurn(0, 0, '断', '层'), true) // tail seq 5, but baseSeq is 10 → hole + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.open() + const windowBefore = session.eventSource.getSnapshot() + await session.loadOlder() + const snapshot = session.getSnapshot() + expect(session.eventSource.getSnapshot().entries).toEqual(windowBefore.entries) + expect(snapshot.hasMore).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('ignores loadOlder while one is in flight (single request)', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.open() + const gate = deferred>>() + api.onHistory = () => gate.promise + const first = session.loadOlder() + const second = session.loadOlder() + gate.resolve(ok({ + records: entries(plainTurn(0, 0, 'a', 'b')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await Promise.all([first, second]) + expect(api.callsOf('session.follow')).toHaveLength(1) + expect(api.callsOf('session.history')).toHaveLength(1) + }) +}) + +describe('prompt and cancel errors', () => { + it('routes an addressed child through non-activating history, continuation prompt, and interrupt only', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + const cancelled = await session.cancel() + + expect(prompted).toEqual({ ok: true, value: { accepted: true } }) + expect(cancelled).toEqual({ ok: true, value: { accepted: true } }) + expect(api.callsOf('session.follow')).toEqual([ + { + address: { + kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'continuable', + }, + maxMessages: 50, + }, + ]) + expect(api.callsOf('subagent.history')).toEqual([]) + expect(api.callsOf('subagents.prompt')).toEqual([ + { + requestId: expect.any(String) as unknown as string, + parentSessionId: PARENT, childSessionId: SID, + mode: 'continuable', + content: [{ type: 'text', text: '继续' }], + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }, + ]) + expect(api.callsOf('subagents.interruptByParent')).toEqual([ + { childSessionId: SID, parentSessionId: PARENT, mode: 'continuable' }, + ]) + expect(api.callsOf('session.history')).toEqual([]) + expect(api.callsOf('session.prompt')).toEqual([]) + expect(api.callsOf('session.cancel')).toEqual([]) + // A successful interrupt leaves no stop error behind. + expect(session.getSnapshot().promptError).toBeNull() + expect(session.getSnapshot().subagent).toEqual({ + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + }) + + it('lands an interrupt business failure in promptError with op=stop', async () => { + const api = new FakeApiClient() + api.onSubagentInterrupt = () => Promise.resolve(remoteErr({ + code: 'subagent-unauthorized', message: 'nope', details: { childSessionId: SID }, + })) + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'continuable' }, + parentAvailable: true, + }) + await session.open() + const cancelled = await session.cancel() + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-unauthorized' } }) + expect(session.getSnapshot().promptError).toMatchObject({ + op: 'stop', error: { code: 'subagent-unauthorized' }, + }) + }) + + it('keeps one-shot history readable without exposing prompt or cancel transport', async () => { + const api = new FakeApiClient() + const session = new Session(SID, fakeRemote(api), { + address: { parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot' }, + }) + await session.open() + const prompted = await session.prompt([{ type: 'text', text: '继续' }], 'queue') + const cancelled = await session.cancel() + + expect(prompted).toMatchObject({ ok: false, error: { code: 'subagent-not-resumable' } }) + expect(cancelled).toMatchObject({ ok: false, error: { code: 'subagent-delivery-unavailable' } }) + expect(api.callsOf('session.follow')).toEqual([ + { + address: { + kind: 'subagent', parentSessionId: PARENT, childSessionId: SID, mode: 'one-shot', + }, + maxMessages: 50, + }, + ]) + expect(api.callsOf('subagent.history')).toEqual([]) + expect(api.callsOf('subagents.prompt')).toEqual([]) + expect(api.callsOf('subagents.interruptByParent')).toEqual([]) + expect(api.callsOf('session.cancel')).toEqual([]) + }) + + it('publishes the first-prompt lifecycle synchronously before the Remote settles', async () => { + const { api, session } = makeSession() + session.handleBlank(true) + expect(session.getSnapshot()).toMatchObject({ + blank: true, promptAttempted: false, awaitingFirstTurn: false, + }) + const inFlight = session.prompt([{ type: 'text', text: '要发的' }], 'queue') + expect(session.getSnapshot()).toMatchObject({ + blank: true, promptAttempted: true, awaitingFirstTurn: true, + }) + const result = await inFlight + expect(result.ok).toBe(true) + expect(session.getSnapshot()).toMatchObject({ + blank: false, promptAttempted: true, awaitingFirstTurn: true, + }) + expect(api.callsOf('session.prompt')).toMatchObject([{ + sessionId: SID, + mode: 'queue', + content: [{ type: 'text', text: '要发的' }], + clientTimeZone: new Intl.DateTimeFormat().resolvedOptions().timeZone, + }]) + session.handleRunning(true) + expect(session.getSnapshot()).toMatchObject({ running: true, awaitingFirstTurn: false }) + }) + + it('keeps the attempted-first-prompt state when the Host rejects the prompt', async () => { + const { api, session } = makeSession() + session.handleBlank(true) + api.onPrompt = () => Promise.resolve(err({ code: 'agent-busy', message: 'busy', details: { reason: 'x' } })) + const result = await session.prompt([{ type: 'text', text: '失败的' }], 'queue') + expect(result.ok).toBe(false) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'agent-busy' } }) + expect(session.getSnapshot()).toMatchObject({ + blank: true, promptAttempted: true, awaitingFirstTurn: true, + }) + }) + + it('lands cancel failures in promptError with op=stop', async () => { + const { api, session } = makeSession() + api.onCancel = () => Promise.reject(new Error('cancel transport down')) + const result = await session.cancel() + expect(result.ok).toBe(false) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'internal' } }) + }) + + it('reads session-authorized attachment bytes and keeps the opaque id on the wire', async () => { + const { api, session } = makeSession() + const result = await session.readAttachment('attachment-1' as never) + expect(result).toEqual({ + ok: true, + value: { + attachment: { attachmentId: 'a', mediaType: 'image/png', bytes: 1, width: 1, height: 1 }, + data: Uint8Array.of(0), + }, + }) + expect(api.callsOf('session.attachment')).toEqual([{ + sessionId: SID, attachmentId: 'attachment-1', + }]) + }) +}) + +describe('rename', () => { + it('settles the title projection cell from the unary response (higher-seq-wins vs the push frame)', async () => { + const { api, session } = makeSession() + api.onRename = () => Promise.resolve(ok({ title: '正名', seq: 7 })) + const result = await session.rename(' 正名 ') + expect(result).toMatchObject({ ok: true, value: { title: '正名', seq: 7 } }) + expect(api.callsOf('session.rename')).toMatchObject([{ sessionId: SID, title: ' 正名 ' }]) + expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') + // A stale lower-seq apply (the push-frame path routes into this same + // store) must not roll the settled value back. + session.projections.apply('title', '旧名', 3) + expect(session.projections.faceOf('title').getSnapshot()).toBe('正名') + }) + + it('returns the business error untouched and folds a transport throw to internal', async () => { + const { api, session } = makeSession() + api.onRename = () => Promise.resolve(err({ + code: 'title-invalid', message: 'empty', details: { sessionId: SID }, + } as never)) + const rejected = await session.rename(' ') + expect(rejected).toMatchObject({ ok: false, error: { code: 'title-invalid' } }) + expect(session.projections.faceOf('title').getSnapshot()).toBeUndefined() + api.onRename = () => Promise.reject(new Error('rename transport down')) + const folded = await session.rename('x') + expect(folded).toMatchObject({ ok: false, error: { code: 'internal' } }) + }) +}) + +describe('remaining branches', () => { + it('prompt transport throw folds to internal promptError', async () => { + const { api, session } = makeSession() + api.onPrompt = () => Promise.reject(new Error('prompt wire down')) + const result = await session.prompt([{ type: 'text', text: 'x' }], 'queue') + expect(result.ok).toBe(false) + expect(session.getSnapshot().promptError).toMatchObject({ op: 'send', error: { code: 'internal', message: 'prompt wire down' } }) + }) + + it('cancel business error also lands op=stop promptError', async () => { + const { api, session } = makeSession() + api.onCancel = () => Promise.resolve(err({ code: 'agent-busy', message: 'nope', details: { reason: 'r' } })) + await session.cancel() + expect(session.getSnapshot().promptError).toMatchObject({ op: 'stop', error: { code: 'agent-busy' } }) + }) + + it('loadOlder guards: not-open/no-hasMore no-op, err result kept window, empty page updates hasMore, throw fail-soft', async () => { + const { api, session } = makeSession() + await session.loadOlder() // cold: no-op, zero calls + expect(api.calls).toEqual([]) + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.open() + // err result: window unchanged + api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + await session.loadOlder() + expect(eventSeqs(session)).toHaveLength(6) + expect(session.getSnapshot().hasMore).toBe(true) + // empty page: hasMore adopts the response + api.onHistory = () => histResponse([], false) + await session.loadOlder() + expect(session.getSnapshot().hasMore).toBe(false) + // hasMore false now: further loadOlder is a guard no-op + const calls = api.calls.length + await session.loadOlder() + expect(api.calls.length).toBe(calls) + // throw path: fail-soft with console.error + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) + try { + await session.resync() + api.onHistory = () => histResponse(plainTurn(6, 1, 'x', 'y'), true) + await session.resync() + api.onHistory = () => Promise.reject(new Error('page wire down')) + await session.loadOlder() + expect(errorSpy).toHaveBeenCalled() + expect(session.getSnapshot().loadingOlder).toBe(false) + } finally { + errorSpy.mockRestore() + } + }) + + it('subscribe delivers snapshot-change notifications and unsubscribes', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + let notified = 0 + const unsubscribe = session.subscribe(() => { notified++ }) + await session.open() + await new Promise(resolve => setTimeout(resolve, 0)) + expect(notified).toBeGreaterThan(0) + const seen = notified + unsubscribe() + session.handleRunning(true) // any snapshot mutation; the listener must stay silent + await new Promise(resolve => setTimeout(resolve, 0)) + expect(notified).toBe(seen) + }) + + it('rejects an opening page that does not end at the opening cursor', async () => { + const { api, session } = makeSession() + let call = 0 + api.onHistory = () => { + call++ + return histResponse(plainTurn(0, 0, 'a', 'b')) + } + api.followCursor = 11 + await session.open() + expect(call).toBe(1) + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('error') + expect(snapshot.openError).toMatchObject({ + code: 'internal', message: 'session event stream page did not end at its requested cursor', + }) + expect(eventSeqs(session)).toEqual([]) + }) + + it('deduplicates repeated running flips and records removal', () => { + const { session } = makeSession() + const before = session.getSnapshot() + session.handleRunning(false) // already false: dedup branch + expect(session.getSnapshot()).toBe(before) + session.handleRemoved() + expect(session.getSnapshot().removed).toBe(true) + }) + + it('drops live events while cold/error (no window upkeep)', async () => { + const { api, session } = makeSession() + await follow(api, ev.user(0, '冷态帧')) + expect(eventSeqs(session)).toEqual([]) + api.onHistory = () => Promise.resolve(err({ code: 'internal', message: 'x', details: {} })) + await session.open() + await follow(api, ev.user(0, '错态帧')) + expect(eventSeqs(session)).toEqual([]) + }) + + it('preserves a Host-reported failure that terminates the live source', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const failure = { + code: 'session-not-found', + message: 'session disappeared', + details: { sessionId: SID }, + } + + api.failStreams(new RemoteStreamError(failure.code, failure.message, failure.details)) + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + + expect(session.getSnapshot().openError).toEqual(failure) + }) + + it('coalesces queued gap frames behind one repair and exposes a failed repair', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const gate = deferred>>() + let repairs = 0 + api.onHistory = () => { + repairs++ + return gate.promise + } + const deliveries = Promise.all([ + follow(api, ev.user(9, '洞一')), + follow(api, ev.user(10, '洞二')), + ]) + await vi.waitFor(() => { expect(repairs).toBe(1) }) + gate.reject(new Error('repair wire down')) + await deliveries + await vi.waitFor(() => { expect(session.getSnapshot().openState).toBe('error') }) + expect(session.getSnapshot().openError).toMatchObject({ code: 'internal', message: 'repair wire down' }) + expect(eventSeqs(session)).toHaveLength(6) + }) + + it('doOpen transport throw of a stale generation is swallowed (generation guard in catch)', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const opening = session.open() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + const resynced = session.resync() + stale.reject(new Error('stale wire')) + await Promise.all([opening, resynced]) + expect(session.getSnapshot().openState).toBe('open') // stale catch did not write error + }) + + it('drops a stale doOpen whose history resolved successfully after resync superseded it', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const opening = session.open() + api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) + const resynced = session.resync() + stale.resolve(ok({ + records: entries(plainTurn(0, 0, '旧', '代')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, + })) // success, but its generation is gone + await Promise.all([opening, resynced]) + expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq)) + }) + + it('drops a gap repair superseded by a full resync while its pull was in flight', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const repairPull = deferred>>() + api.onHistory = () => repairPull.promise + const delivery = follow(api, ev.user(9, '洞')) + await vi.waitFor(() => { expect(api.callsOf('session.history')).toHaveLength(1) }) + api.onHistory = () => histResponse(plainTurn(6, 1, 'c', 'd')) + const resynced = session.resync() // bumps the generation + repairPull.resolve(ok({ + records: entries(plainTurn(0, 0, '旧', '页')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'stale' }, + })) // repair result: stale, dropped + await Promise.all([delivery, resynced]) + expect(eventSeqs(session)).toEqual(plainTurn(6, 1, 'c', 'd').map(event => event.seq)) + }) + + it('successful cancel leaves no promptError', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + const result = await session.cancel() + expect(result.ok).toBe(true) + expect(session.getSnapshot().promptError).toBeNull() + }) + + it('dispose is a reserved no-op on resident instances', async () => { + const { session } = makeSession() + await expect(session.dispose()).resolves.toBeUndefined() + }) + + it('carries raw history and follow events through the event feed', async () => { + const { api, session } = makeSession() + const historyCall = ev.toolCall(6, 1, 'h1', 'bash', '{"cmd":"pwd"}') + const historyResult = ev.toolResult(7, 1, 'h1', 'done') + api.onHistory = () => Promise.resolve(ok({ + records: [ + ...entries(plainTurn(0, 0, 'a', 'b')), + { type: 'event', event: historyCall }, + { type: 'event', event: historyResult }, + ] as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await session.open() + expect(windowEntries(session).slice(-2)).toEqual([ + { type: 'event', event: historyCall }, + { type: 'event', event: historyResult }, + ]) + const liveCall = ev.toolCall(8, 2, 'l1', 'write', '{"file_path":"a.ts"}') + await follow(api, liveCall) + expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveCall }) + const liveResult = ev.toolResult(9, 2, 'l1', 'ok') + await follow(api, liveResult) + expect(windowEntries(session).at(-1)).toEqual({ type: 'event', event: liveResult }) + }) +}) + +describe('resync', () => { + it('keeps the old feed until the reconnect snapshot, then repairs queued live gaps', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '旧', '窗')) + await session.open() + const oldWindow = session.eventSource.getSnapshot() + const replacement = deferred>>() + api.followCursor = 15 + api.onHistory = () => replacement.promise + const publications: ReturnType[] = [] + const off = session.eventSource.subscribe(() => { + publications.push(session.eventSource.getSnapshot()) + }) + + const syncing = session.resync() + await vi.waitFor(() => { expect(api.callsOf('session.follow')).toHaveLength(2) }) + expect(session.eventSource.getSnapshot()).toBe(oldWindow) + expect(publications).toEqual([]) + + api.onHistory = () => histResponse([ + ...plainTurn(10, 2, '终', '页'), + ev.user(16, '后到低位'), + ev.user(17, '后到高位'), + ]) + const liveDeliveries = Promise.all([ + follow(api, ev.user(17, '后到高位')), + follow(api, ev.user(16, '后到低位')), + ]) + expect(session.eventSource.getSnapshot()).toBe(oldWindow) + replacement.resolve(ok({ + records: entries(plainTurn(10, 2, '终', '页')) as never[], + hasMore: false, + modelSelection: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + })) + await Promise.all([syncing, liveDeliveries]) + await vi.waitFor(() => { + expect(eventSeqs(session)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) + }) + + expect(publications).toHaveLength(2) + expect(publications.map(snapshot => snapshot.change.kind)).toEqual(['replace', 'replace']) + expect(publications[0]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15]) + expect(publications[1]?.entries.map(entry => entry.event.seq)).toEqual([10, 11, 12, 13, 14, 15, 16, 17]) + off() + }) + + it('rebuilds the window without clearing control state; cold instances no-op', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, 'a', 'b')) + await session.open() + session.handleRunning(true) + session.handleAgentError('still visible') + api.onHistory = () => histResponse([...plainTurn(0, 0, 'a', 'b'), ...plainTurn(6, 1, 'c', 'd')]) + await session.resync() + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') + expect(snapshot.running).toBe(true) + expect(snapshot.lastAgentError).toBe('still visible') + expect(eventSeqs(session)).toHaveLength(12) + + const cold = makeSession() + await cold.session.resync() + expect(cold.api.calls).toEqual([]) // never opened: no traffic + }) + + it('drops a stale in-flight open superseded by resync (generation guard)', async () => { + const { api, session } = makeSession() + const stale = deferred>>() + api.onHistory = () => stale.promise + const firstOpen = session.open() + api.onHistory = () => histResponse(plainTurn(6, 1, '新', '代')) + const resynced = session.resync() + stale.reject(new Error('dead connection')) // the doomed pre-disconnect request fails late + await firstOpen + await resynced + const snapshot = session.getSnapshot() + expect(snapshot.openState).toBe('open') // stale failure did not settle the fresh generation into error + expect(eventSeqs(session)).toEqual(plainTurn(6, 1, '新', '代').map(event => event.seq)) + }) + +}) + +describe('snapshot ownership', () => { + it('publishes event-window appends without changing an unrelated Session snapshot', async () => { + const { api, session } = makeSession() + api.onHistory = () => histResponse(plainTurn(0, 0, '稳', '定')) + await session.open() + const sessionBefore = session.getSnapshot() + const windowBefore = session.eventSource.getSnapshot() + const firstEntry = windowBefore.entries[0] + await follow(api, ev.user(6, '追加')) + const windowAfter = session.eventSource.getSnapshot() + expect(session.getSnapshot()).toBe(sessionBefore) + expect(windowAfter).not.toBe(windowBefore) + expect(windowAfter.entries[0]).toBe(firstEntry) + expect(windowAfter.change).toMatchObject({ kind: 'append' }) + }) +}) diff --git a/packages/api/session-controller/tests/sessions-service.client.spec.ts b/packages/api/session-controller/tests/sessions-service.client.spec.ts new file mode 100644 index 0000000000..4e4bade67c --- /dev/null +++ b/packages/api/session-controller/tests/sessions-service.client.spec.ts @@ -0,0 +1,879 @@ +/** + * ClientSessions: list store projection (manager → {ids, byId, current} + * with derived titles), the current-selection account (open validation and + * persisted mask semantics), scope-tree + * lifecycle (lazy mint / frozen survival / removed teardown with staged + * deferral — the stage follows list.current), binding identity, breadcrumb + * projection, create. + */ +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { ClientSessions, SessionCreateError } from '../src/client/sessions/service.ts' +import { scopeOf } from '../src/client/scope.ts' +import type { SessionFollowFrame } from '../src/types.ts' +import { + FakeApiClient, + deferred, + err, + fakeRemote, + ok, + remoteOk, + type RuntimeRemotes, +} from './fake-api.client.ts' + +const sid = (s: string): SessionId => s as SessionId + +interface Bench { + ctx: Context + api: FakeApiClient + svc: ClientSessions +} + +function bench(configureRemote?: (remote: RuntimeRemotes) => RuntimeRemotes): Bench { + const ctx = new Context() + const api = new FakeApiClient() + const remote = fakeRemote(api) + const svc = new ClientSessions(ctx, configureRemote?.(remote) ?? remote) + return { ctx, api, svc } +} + +/** Refresh the manager list from programmable rows and flush the microtask batch. */ +type FeedRow = { + id: string + cwd?: string + parentId?: string + origin?: 'subagent' + running?: boolean + blank?: boolean + projections?: Record +} + +async function feedList(b: Bench, rows: FeedRow[]): Promise { + b.api.onList = () => Promise.resolve(ok({ + items: rows.map(r => ({ + sessionId: sid(r.id), updatedAt: 1, running: r.running ?? false, blank: r.blank ?? false, + ...(r.cwd !== undefined ? { cwd: r.cwd } : {}), + ...(r.parentId !== undefined ? { parentSessionId: sid(r.parentId) } : {}), + ...(r.origin !== undefined ? { origin: r.origin } : {}), + ...(r.projections === undefined + ? {} + : { projections: { asOfSeq: 0, values: r.projections } }), + })), + }) as never) + await b.svc.refresh() + await Promise.resolve() // manager notifier flush +} + +describe('list store projection', () => { + it('projects durable titles separately from cwd/id display fallbacks and parent links', async () => { + const b = bench() + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'title', value: 'Durable title', seq: 2, + }) + await feedList(b, [ + { id: 's1', cwd: '/home/u/proj-a/' }, + { id: 's2', parentId: 's1', origin: 'subagent', running: true }, + ]) + const state = b.svc.list.getSnapshot() + expect(state.ids).toEqual(['s1', 's2']) + expect(state.byId[sid('s1')]).toMatchObject({ title: 'Durable title', displayTitle: 'Durable title', cwd: '/home/u/proj-a/' }) + expect(state.byId[sid('s2')]).toMatchObject({ + displayTitle: 's2', parentId: 's1', origin: 'subagent', running: true, + }) + expect(state.byId[sid('s2')]?.title).toBeUndefined() + }) + + it('reprojects a blank session from the generic agent-preset projection', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, projections: { agentPreset: 'standard' } }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('standard') + + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('s1'), key: 'agentPreset', value: 'minimal', seq: 1, + }) + await Promise.resolve() + + expect(b.svc.list.getSnapshot().byId[sid('s1')]?.projectionValues?.agentPreset).toBe('minimal') + }) + + it('reflects live increments (host stream via manager) into the store', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.handleSessionAdded({ + sessionId: sid('s2'), updatedAt: 2, running: false, blank: true, + }) + await Promise.resolve() + expect(b.svc.list.getSnapshot().ids).toContain('s2') + }) +}) + +describe('search', () => { + it('delegates transient content search without changing the list snapshot', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const before = b.svc.list.getSnapshot() + b.api.onSearch = () => Promise.resolve(ok({ + items: [{ sessionId: sid('s1'), snippet: 'matching excerpt' }], + hasMore: false, + })) + const signal = new AbortController().signal + + await expect(b.svc.search('needle', signal)).resolves.toEqual({ + ok: true, + value: { + items: [{ sessionId: 's1', snippet: 'matching excerpt' }], + hasMore: false, + }, + }) + expect(b.api.lastSearchSignal).toBe(signal) + expect(b.svc.list.getSnapshot()).toBe(before) + }) +}) + +describe('scope tree', () => { + it('retains a Host-addressed scope until the first Session baseline owns pruning', async () => { + const b = bench() + const scoped = b.svc.resolveAgentScope(sid('s-early')) + expect(scopeOf(scoped)).toBe('s-early') + + b.svc.handleControlFrame({ + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + }) + await Promise.resolve() + expect(b.svc.resolveAgentScope(sid('s-early'))).toBe(scoped) + + await feedList(b, []) + expect(b.svc.scope(sid('s-early'))).toBeUndefined() + }) + + it('mints lazily on first resolution, tags the ctx, and keeps binding identity stable', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + expect(b.svc.scope(sid('unknown'))).toBeUndefined() + const scoped = b.svc.scope(sid('s1')) + expect(scoped).toBeDefined() + expect(scopeOf(scoped as Context)).toBe('s1') + expect(scopeOf(b.ctx)).toBeUndefined() + const binding = b.svc.binding(sid('s1')) + b.svc.open(sid('s1')) + expect(b.svc.sessionOf(scoped as Context)).toBe(binding?.session) + expect(b.svc.binding(sid('s1'))).toBe(binding) + expect(binding?.ctx).toBe(scoped) + }) + + it('tears down an off-stage removed session but defers the staged one until the stage moves', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + const ctx1 = b.svc.scope(sid('s1')) + b.svc.open(sid('s1')) // s1 staged (current) + b.svc.scope(sid('s2')) // s2 scoped but off stage + + await feedList(b, [{ id: 's1' }]) // s2 removed, off stage: torn down + expect(b.svc.scope(sid('s2'))).toBeUndefined() + + await feedList(b, []) // s1 removed while staged (current masks): deferred, scope survives + expect(b.svc.scope(sid('s1'))).toBe(ctx1) + + await feedList(b, [{ id: 's3' }]) + b.svc.open(sid('s3')) // stage moves: deferred teardown sweeps s1 + expect(b.svc.scope(sid('s1'))).toBeUndefined() + }) + + it('keeps the scope when the session merely stops running (frozen ≠ removed)', async () => { + const b = bench() + await feedList(b, [{ id: 's1', running: true }]) + const scoped = b.svc.scope(sid('s1')) + await feedList(b, [{ id: 's1', running: false }]) + expect(b.svc.scope(sid('s1'))).toBe(scoped) + }) + + it('cancels a deferred teardown when the id reappears in the list', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + const scoped = b.svc.scope(sid('s1')) + b.svc.open(sid('s1')) + await feedList(b, []) // removed while staged → deferred + await feedList(b, [{ id: 's1' }, { id: 's2' }]) // reappears (current resurfaces, stage unchanged) + b.svc.open(sid('s2')) // stage moves; sweep must NOT tear down the re-listed s1 + expect(b.svc.scope(sid('s1'))).toBe(scoped) + }) + + it('closes an opened journal when its removed scope drops', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + const session = b.svc.binding(sid('s1'))?.session + if (session === undefined) throw new Error('expected the selected Session binding') + await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(1) }) + const notified = vi.fn() + session.subscribe(notified) + + await feedList(b, []) + await feedList(b, [{ id: 's2' }]) + b.svc.open(sid('s2')) + + await vi.waitFor(() => { expect(b.api.activeFollows(sid('s1'))).toBe(0) }) + notified.mockClear() + await b.api.pushFollow(sid('s1'), { + type: 'event', + event: { seq: 0, timestamp: 0, type: 'turn/start', data: { turn: 0 } } as never, + }) + await Promise.resolve() + expect(b.api.followStarts.filter(id => id === sid('s1'))).toHaveLength(1) + expect(notified).not.toHaveBeenCalled() + }) +}) + +describe('Agent scope disposal lifecycle', () => { + it('root disposal runs Agent scope effects', async () => { + const b = bench() + const readiness = b.ctx.plugin(() => undefined) + await readiness + b.svc.handleSessionAdded({ + sessionId: sid('live'), updatedAt: 1, running: false, blank: true, + }) + await Promise.resolve() + const scoped = b.svc.scope(sid('live')) + if (scoped === undefined) throw new Error('fixture Agent Context was not minted') + await scoped.fiber.await() + const scopeDisposed = vi.fn() + scoped.effect(() => scopeDisposed, 'fixture Agent scope effect') + await b.ctx.fiber.dispose() + + expect(scopeDisposed).toHaveBeenCalledOnce() + expect(b.svc.sessionOf(scoped)).toBeUndefined() + }) + + it('root disposal waits for an opened Session source to finish closing', async () => { + const closeGate = deferred() + const abortObserved = vi.fn() + let followSignal: AbortSignal | undefined + const b = bench(remote => ({ + ...remote, + session: { + ...remote.session, + follow: (request, signal) => { + if (signal === undefined) throw new Error('fixture requires a signal') + followSignal = signal + let opened = false + return { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (!opened) { + opened = true + return Promise.resolve({ + done: false, + value: { + type: 'snapshot', + header: { + version: 0, + id: request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId, + createdAt: 0, + }, + cursor: -1, + records: [], + hasMore: false, + projections: { asOfSeq: -1, values: {} }, + } as const, + }) + } + return new Promise((_resolve, reject) => { + signal.addEventListener('abort', () => { + abortObserved() + void closeGate.promise.then(() => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + }) + }, { once: true }) + }) + }, + }), + } + }, + }, + })) + const readiness = b.ctx.plugin(() => undefined) + await readiness + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + await vi.waitFor(() => { + expect(b.svc.binding(sid('s1'))?.session.getSnapshot().openState).toBe('open') + }) + + const disposal = b.ctx.fiber.dispose() + const settled = vi.fn() + const observed = disposal.then(settled) + + await vi.waitFor(() => { expect(abortObserved).toHaveBeenCalledOnce() }) + expect(followSignal?.aborted).toBe(true) + expect(settled).not.toHaveBeenCalled() + + closeGate.resolve(undefined) + await observed + expect(settled).toHaveBeenCalledOnce() + }) + + it('root disposal joins every Session drop already started by pruning under load', async () => { + const closeGates = new Map>>() + const aborted = new Set() + const b = bench(remote => ({ + ...remote, + session: { + ...remote.session, + follow: (request, signal) => { + if (signal === undefined) throw new Error('fixture requires a signal') + const sessionId = request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId + const closeGate = deferred() + closeGates.set(sessionId, closeGate) + let opened = false + return { + [Symbol.asyncIterator]: () => ({ + next: () => { + if (!opened) { + opened = true + return Promise.resolve({ + done: false, + value: { + type: 'snapshot', + header: { version: 0, id: sessionId, createdAt: 0 }, + cursor: -1, + records: [], + hasMore: false, + projections: { asOfSeq: -1, values: {} }, + } as const, + }) + } + return new Promise>((_resolve, reject) => { + signal.addEventListener('abort', () => { + aborted.add(sessionId) + void closeGate.promise.then(() => { + reject(signal.reason instanceof Error + ? signal.reason + : new Error(String(signal.reason))) + }) + }, { once: true }) + }) + }, + }), + } + }, + }, + })) + const readiness = b.ctx.plugin(() => undefined) + await readiness + const sessionIds = Array.from({ length: 24 }, (_, index) => sid(`load-${String(index)}`)) + const retained = sessionIds.at(-1) + const held = sessionIds[0] + if (retained === undefined || held === undefined) throw new Error('fixture requires sessions') + await feedList(b, sessionIds.map(id => ({ id }))) + for (const id of sessionIds) b.svc.open(id) + await vi.waitFor(() => { + for (const id of sessionIds) { + expect(b.svc.binding(id)?.session.getSnapshot().openState).toBe('open') + } + }) + + const pruned = sessionIds.slice(0, -1) + await feedList(b, [{ id: retained }]) + await vi.waitFor(() => { expect(aborted.size).toBe(pruned.length) }) + for (const id of pruned) expect(b.svc.scope(id)).toBeUndefined() + + const disposal = b.ctx.fiber.dispose() + const settled = vi.fn() + const observed = disposal.then(settled) + await vi.waitFor(() => { expect(aborted.size).toBe(sessionIds.length) }) + + const otherClosures: Promise[] = [] + for (const [id, gate] of closeGates) { + if (id === held) continue + gate.resolve(undefined) + otherClosures.push(gate.promise) + } + await Promise.all(otherClosures) + await new Promise((resolve) => { setTimeout(resolve, 0) }) + expect(settled).not.toHaveBeenCalled() + + closeGates.get(held)?.resolve(undefined) + await observed + expect(settled).toHaveBeenCalledOnce() + }) +}) + +describe('current selection (migrated from ui-layout, arbitrated into the list snapshot)', () => { + afterEach(() => { vi.unstubAllGlobals() }) + + it('open() writes list.current; unknown ids fail loud', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + expect(b.svc.list.getSnapshot().current).toBeUndefined() + b.svc.open(sid('s1')) + expect(b.svc.list.getSnapshot().current).toBe('s1') + expect(() => { b.svc.open(sid('ghost')) }).toThrow(/unknown session ghost/) + expect(b.svc.list.getSnapshot().current).toBe('s1') // failed open leaves the selection alone + }) + + it('clear() blanks list.current and the persisted selection', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + removeItem: (k: string) => { storage.delete(k) }, + clear: () => { storage.clear() }, + }) + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + expect(storage.get('dsh.sessions.current')).toContain('s1') + b.svc.clear() + expect(b.svc.list.getSnapshot().current).toBeUndefined() + // Persisted wipe: a fresh service with the same storage stays on empty. + const again = bench() + await feedList(again, [{ id: 's1' }]) + expect(again.svc.list.getSnapshot().current).toBeUndefined() + }) + + it('masks (not destroys) the selection while its session is off the list', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + b.svc.open(sid('s1')) + await feedList(b, [{ id: 's2' }]) // s1 removed → current falls to the empty state + expect(b.svc.list.getSnapshot().current).toBeUndefined() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) // s1 returns → selection resurfaces + expect(b.svc.list.getSnapshot().current).toBe('s1') + }) + + it('persists the selection under dsh.sessions.current and rehydrates it into a fresh service', async () => { + const storage = new Map() + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + }) + const first = bench() + await feedList(first, [{ id: 's1' }]) + first.svc.open(sid('s1')) + expect(storage.get('dsh.sessions.current')).toContain('s1') + // A fresh boot (same storage) recovers the selection once the list holds the session. + const second = bench() + await feedList(second, [{ id: 's1' }]) + expect(second.svc.list.getSnapshot().current).toBe('s1') + }) +}) + +describe('binding and stage lifecycle', () => { + it('binding() is pure resolution: no staging, no deferred sweep', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + b.svc.open(sid('s1')) // staged + b.svc.binding(sid('s2')) // resolution only — must NOT move the stage + await feedList(b, [{ id: 's2' }]) // s1 removed: still staged → deferred, scope survives + expect(b.svc.scope(sid('s1'))).toBeDefined() + }) + + it('staging (current write) opens the session event window; resolution and re-staging do not re-pull', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }, { id: 's2' }]) + const followStarts = () => b.api.followStarts.map(String) + // Resolution is addressing, not staging: no window pull. + b.svc.scope(sid('s1')) + b.svc.binding(sid('s1')) + expect(followStarts()).toEqual([]) + b.svc.open(sid('s1')) + await vi.waitFor(() => { + expect(followStarts()).toEqual(['s1']) + }) + // Same current again: no second pull. + b.svc.open(sid('s1')) + expect(followStarts()).toHaveLength(1) + // Stage moves: the new occupant opens. + b.svc.open(sid('s2')) + await vi.waitFor(() => { + expect(followStarts()).toEqual(['s1', 's2']) + }) + }) + + it('startup restore: a persisted selection validated by the first projection opens its window unprompted', async () => { + const storage = new Map([ + ['dsh.sessions.current', JSON.stringify({ sessionId: 's1' })], + ]) + vi.stubGlobal('localStorage', { + getItem: (k: string) => storage.get(k) ?? null, + setItem: (k: string, v: string) => { storage.set(k, v) }, + }) + try { + const b = bench() + expect(b.api.followStarts).toEqual([]) + await feedList(b, [{ id: 's1' }]) // projection validates the persisted id → current lands → stage follows + await vi.waitFor(() => { + expect(b.api.followStarts.map(String)).toEqual(['s1']) + }) + } finally { + vi.unstubAllGlobals() + } + }) +}) + +describe('catalog-addressed navigation', () => { + it('uses catalog labels for a listed addressed route', async () => { + const b = bench() + b.api.onSubagentList = (payload) => { + const parentSessionId = payload as SessionId + if (parentSessionId === sid('root')) { + return Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', + activity: 'inactive', hasChildren: true, + }] as never[], + parentAvailable: true, + })) + } + if (parentSessionId === sid('child')) { + return Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: false, + })) + } + return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) + } + await feedList(b, [ + { id: 'root' }, + { id: 'child', cwd: '/summary-child', parentId: 'root', origin: 'subagent' }, + { id: 'grandchild', cwd: '/summary-grandchild', parentId: 'child', origin: 'subagent' }, + ]) + await b.svc.refreshSubagents(sid('root')) + await b.svc.refreshSubagents(sid('child')) + b.svc.openSubagent({ + parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable', + }) + + expect(b.svc.list.getSnapshot().byId[sid('child')]?.displayTitle).toBe('Child') + expect(b.svc.list.getSnapshot().byId[sid('grandchild')]?.displayTitle).toBe('Grandchild') + }) + + it('projects a directly opened descendant route without retaining ancestor scopes or addresses', async () => { + const b = bench() + b.api.onSubagentList = (payload) => { + const parentSessionId = payload as SessionId + if (parentSessionId === sid('root')) { + return Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: sid('child'), mode: 'continuable', label: 'Child', + activity: 'inactive', hasChildren: true, + }] as never[], + parentAvailable: true, + })) + } + if (parentSessionId === sid('child')) { + return Promise.resolve(remoteOk({ + entries: [{ + kind: 'child', id: sid('grandchild'), mode: 'continuable', label: 'Grandchild', + activity: 'inactive', hasChildren: false, + }] as never[], + parentAvailable: false, + })) + } + return Promise.resolve(remoteOk({ entries: [], parentAvailable: false })) + } + await feedList(b, [{ id: 'root' }]) + await b.svc.refreshSubagents(sid('root')) + await b.svc.refreshSubagents(sid('child')) + b.svc.openSubagent({ + parentSessionId: sid('child'), childSessionId: sid('grandchild'), mode: 'continuable', + }) + + const list = b.svc.list.getSnapshot() + expect(list.ids).toEqual([sid('root')]) + expect(list.byId[sid('child')]).toMatchObject({ parentId: sid('root'), origin: 'subagent' }) + expect(list.byId[sid('grandchild')]).toMatchObject({ parentId: sid('child'), origin: 'subagent' }) + expect(b.svc.binding(sid('child'))).toBeUndefined() + expect(b.svc.subagentAddress(sid('child'))).toBeUndefined() + + b.svc.open(sid('child')) + expect(b.svc.list.getSnapshot().current).toBe(sid('child')) + expect(b.svc.subagentAddress(sid('child'))).toEqual({ + parentSessionId: sid('root'), childSessionId: sid('child'), mode: 'continuable', + }) + }) +}) + +describe('create', () => { + it('passes a preallocated id and preserves it on ordinary failure', async () => { + const b = bench() + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('fresh') })) + await expect(b.svc.create({ cwd: '/w', sessionId: sid('fresh') })).resolves.toBe('fresh') + expect(b.api.callsOf('session.create')).toEqual([{ cwd: '/w', sessionId: 'fresh' }]) + b.api.onCreate = () => Promise.resolve({ + rpcId: 'e' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: '爆了', details: {} } }, + } as never) + const failure = await b.svc.create({ sessionId: sid('candidate') }).catch((error: unknown) => error) + expect(failure).toBeInstanceOf(SessionCreateError) + expect(failure).toMatchObject({ + requestedSessionId: 'candidate', + rpcError: { code: 'internal', message: '爆了' }, + }) + }) + + it('resolves with the session already listed and binding-resolvable (no flush wait)', async () => { + const b = bench() + b.api.onCreate = () => Promise.resolve(ok({ sessionId: sid('born') })) + const born = await b.svc.create({ workspaceId: 'ws' as never }) + // Synchronously after resolution — the draft hand-off contract: the + // create echo IS the entity entering the client's view (blank row + + // resolvable scope/binding), no notifier flush in between. + expect(b.svc.list.getSnapshot().byId[born]).toMatchObject({ id: 'born', blank: true }) + expect(b.svc.binding(born)).toBeDefined() + expect(b.svc.scope(born)).toBeDefined() + }) + + it('lists the published id after Workspace attachment fails (publication precedes attachment)', async () => { + const b = bench() + b.api.onCreate = () => Promise.resolve({ + rpcId: 'attach' as never, + result: { + ok: false, + error: { + code: 'workspace-attach-failed', message: 'ledger unavailable', + details: { sessionId: sid('published'), workspaceId: 'ws' }, + }, + }, + } as never) + const failure = await b.svc.create({ + workspaceId: 'ws' as never, + sessionId: sid('published'), + }).catch((error: unknown) => error) + await Promise.resolve() + expect(failure).toBeInstanceOf(SessionCreateError) + expect(failure).toMatchObject({ + requestedSessionId: 'published', + rpcError: { code: 'workspace-attach-failed' }, + }) + expect(b.svc.list.getSnapshot().byId[sid('published')]).toMatchObject({ id: 'published', blank: true }) + }) +}) + +describe('fork', () => { + it.each([ + ['Roadmap', 'Roadmap (1)'], + ['Roadmap (1)', 'Roadmap (2)'], + ['计划(1)', '计划(2)'], + ['计划 (9)', '计划 (10)'], + ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => { + const b = bench() + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2, + }) + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.api.onRename = (payload) => { + const { title } = payload as { title: string } + return Promise.resolve(ok({ title, seq: 3 })) + } + + await expect(b.svc.fork({ + sessionId: sid('source'), atSeq: 7, increaseTitle: true, + })).resolves.toBe('child') + + expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }]) + expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }]) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ + title: childTitle, + displayTitle: childTitle, + parentId: 'source', + }) + }) + + it('floors a fractional anchor to the real event seq the wire accepts', async () => { + const b = bench() + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + + // The frozen node of an interrupted turn carries turnEnd.seq - 0.9. + await expect(b.svc.fork({ sessionId: sid('source'), atSeq: 41.1 })).resolves.toBe('child') + + expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 41 }]) + }) + + it('does not rename without the title policy or a durable source title', async () => { + const b = bench() + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child') + expect(b.api.callsOf('session.rename')).toEqual([]) + + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') })) + await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2') + expect(b.api.callsOf('session.rename')).toEqual([]) + }) + + it('rejects when child rename fails while keeping the published child addressable', async () => { + const b = bench() + b.svc.handleControlFrame({ + type: 'projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2, + }) + await feedList(b, [{ id: 'source' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.api.onRename = () => Promise.resolve(err({ + code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, + } as never)) + + await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) + .rejects.toThrow('fork child rename failed: title-invalid: rejected') + expect(b.svc.binding(sid('child'))).toBeDefined() + }) +}) + +describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => { + it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => { + const b = bench() + await feedList(b, []) + expect(b.svc.scope(sid('s-new'))).toBeUndefined() // not in view: no scope, no exceptions + b.svc.handleSessionAdded({ + sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a', + }) + await Promise.resolve() + const scoped = b.svc.scope(sid('s-new')) + expect(scoped).toBeDefined() + expect(scopeOf(scoped as Context)).toBe('s-new') + b.svc.handleSessionRemoved(sid('s-new')) + await Promise.resolve() + expect(b.svc.scope(sid('s-new'))).toBeUndefined() + }) +}) + +describe('blank mirror', () => { + it('flips blank=false from the running:true status frame (cross-client conversion)', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true }]) + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) + b.svc.handleSessionStatus(sid('s1'), true) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false, running: true }) + // The instantiated Session mirrors the same flip. + expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false) + }) + + it('flips blank=false on prompt ACCEPTANCE, not on the attempt', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) + const session = b.svc.binding(sid('s1'))!.session + expect(session.getSnapshot().blank).toBe(true) + const gate = deferred>>() + b.api.onPrompt = () => gate.promise + const send = session.prompt([{ type: 'text', text: 'hi' }], 'queue') + // In flight: still blank (the flip point is the success response, which + // proves the user message reached the host log). + expect(session.getSnapshot().blank).toBe(true) + gate.resolve(ok({ accepted: true as const })) + await send + expect(session.getSnapshot().blank).toBe(false) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false }) + }) + + it('keeps a rejected first prompt blank: hidden and still reusable', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true, cwd: '/w/a' }]) + const session = b.svc.binding(sid('s1'))!.session + b.api.onPrompt = () => Promise.resolve({ + rpcId: 'busy' as never, + result: { ok: false as const, error: { code: 'internal' as const, message: 'agent busy', details: {} } }, + } as never) + const result = await session.prompt([{ type: 'text', text: 'hi' }], 'queue') + expect(result.ok).toBe(false) + // No flip on failure: local stays aligned with the host authority + // (events.length still 0), so the session stays hidden and reusable. + expect(session.getSnapshot().blank).toBe(true) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: true }) + }) + + it('takes session-added blank=true as the hidden birth and list blank as reconnect authority', async () => { + const b = bench() + await feedList(b, []) + b.svc.handleSessionAdded({ + sessionId: sid('s-new'), updatedAt: 2, running: false, blank: true, cwd: '/w/a', + }) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: true }) + // Reconnect re-pull: the summary's blank=false wins (authoritative alignment). + await feedList(b, [{ id: 's-new', blank: false, cwd: '/w/a' }]) + expect(b.svc.list.getSnapshot().byId[sid('s-new')]).toMatchObject({ blank: false }) + }) + + it('never re-blanks: a stale blank=true summary cannot hide an engaged session', async () => { + const b = bench() + await feedList(b, [{ id: 's1', blank: true }]) + const session = b.svc.binding(sid('s1'))!.session + await session.prompt([{ type: 'text', text: 'hi' }], 'queue') + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('s1')]).toMatchObject({ blank: false }) + // The next list pull still claims blank (host hasn't logged the message yet). + await feedList(b, [{ id: 's1', blank: true }]) + expect(b.svc.binding(sid('s1'))?.session.getSnapshot().blank).toBe(false) + }) +}) + +describe('coverage tails (branch duals)', () => { + it('displayTitleOf falls back to the id for empty and separator-only cwd', async () => { + const b = bench() + await feedList(b, [{ id: 'no-base', cwd: '///' }, { id: 'empty-cwd', cwd: '' }]) + const { byId } = b.svc.list.getSnapshot() + expect(byId[sid('no-base')]?.displayTitle).toBe('no-base') + expect(byId[sid('empty-cwd')]?.displayTitle).toBe('empty-cwd') + expect(byId[sid('no-base')]?.title).toBeUndefined() + }) + + it('binding for an unknown session returns undefined and leaves the staged scope intact', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + expect(b.svc.binding(sid('ghost'))).toBeUndefined() + // Stage unchanged: removing s1 defers (still staged), proving the ghost lookup touched nothing. + await feedList(b, []) + expect(b.svc.scope(sid('s1'))).toBeDefined() + }) + + it('a masked current gap holds the stage (no teardown, no re-open) until the stage moves', async () => { + const b = bench() + await feedList(b, [{ id: 's1' }]) + b.svc.open(sid('s1')) + await vi.waitFor(() => { expect(b.api.followStarts).toHaveLength(1) }) + await feedList(b, []) // removed while staged: current masks to undefined, stage holds → deferred + expect(b.svc.scope(sid('s1'))).toBeDefined() + // Resurfacing re-projects current = s1: same stage occupant, no second pull. + await feedList(b, [{ id: 's1' }]) + expect(b.api.followStarts).toHaveLength(1) + expect(b.svc.list.getSnapshot().current).toBe('s1') + }) + + it('sweep hits both deferral edges: staged-id skip and an already-vacated scope record', async () => { + const b = bench() + await feedList(b, [{ id: 'a' }, { id: 'b' }]) + b.svc.scope(sid('a')) + b.svc.open(sid('b')) // stage: b; both scoped + await feedList(b, []) // a removed off stage → torn immediately; b removed staged → deferred + // Move the stage to a THIRD id while b stays deferred: sweep walks a set + // containing b (torn). + await feedList(b, [{ id: 'c' }]) + b.svc.open(sid('c')) + expect(b.svc.scope(sid('b'))).toBeUndefined() + // Deferral for an id whose record was never minted: force the deferral + // via removed list state — sweep must tolerate the missing record. + await feedList(b, []) // c removed while staged → deferred (scope exists) + await feedList(b, [{ id: 'd' }]) + b.svc.open(sid('d')) // sweep tears c + expect(b.svc.scope(sid('c'))).toBeUndefined() + }) + +}) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts new file mode 100644 index 0000000000..ac131ab628 --- /dev/null +++ b/packages/api/session-controller/tests/test-remote.ts @@ -0,0 +1,249 @@ +/** Test-only direct Remote face over the Session Controller's internal controllers. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { ModelSelection as AgentModelSelection } from '@deepseek-ai/dsh-agent' +import type { SessionId } from '@deepseek-ai/dsh-session' +import { + SessionPersistenceCorruptionError, + SessionPersistenceNotFoundError, + SessionPersistenceRevision, + type BorrowedSessionSource, + type SessionInspection, +} from '@deepseek-ai/dsh-session-persistence' +import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' +import SessionQueryEngine from '@deepseek-ai/dsh-session-query' +import { vi } from 'vitest' +import { + TypertRemoteFailure, + type RemoteResult, +} from '@deepseek-ai/dsh-typert-protocol' +import SessionController from '../src/index.ts' +import type { + SessionAttachmentRequest, + SessionAttachmentValue, + SessionCancelRequest, + SessionCancelValue, + SessionControlFrame, + SessionCreateRequest, + SessionCreateValue, + SessionForkRequest, + SessionForkValue, + SessionFollowFrame, + SessionFollowRequest, + SessionListRequest, + SessionListValue, + SessionPage, + SessionPageRequest, + SessionPromptRequest, + SessionPromptValue, + SessionRenameRequest, + SessionRenameValue, + SessionSearchRequest, + SessionSearchValue, + SessionSelectModelRequest, + SessionSelectModelValue, + SessionUpdateQueueRequest, + SessionUpdateQueueValue, +} from '../src/types.ts' + +/** Direct test face matching the generated `ctx.remote.session` unary methods. */ +export interface TestSessionRemote { + list(request: SessionListRequest, signal?: AbortSignal): Promise> + search(request: SessionSearchRequest, signal?: AbortSignal): Promise> + create(request: SessionCreateRequest): Promise> + selectModel(request: SessionSelectModelRequest): Promise> + rename(request: SessionRenameRequest): Promise> + fork(request: SessionForkRequest): Promise> + prompt(request: SessionPromptRequest, signal?: AbortSignal): Promise> + attachment(request: SessionAttachmentRequest): Promise> + updateQueue(request: SessionUpdateQueueRequest): Promise> + cancel(request: SessionCancelRequest): Promise> + page(request: SessionPageRequest, signal?: AbortSignal): Promise> + follow(request: SessionFollowRequest, signal?: AbortSignal): AsyncIterable + control(signal?: AbortSignal): AsyncIterable +} + +/** Dependencies and policy supplied by a Session Controller unit harness. */ +export interface TestSessionRemoteDefaults { + readonly defaultModelSelection: () => AgentModelSelection + readonly cwd: string + readonly coldBlankProbeMaxBytes?: number + readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise +} + +const installed = new WeakMap() + +type LegacyTestPersistence = Record & { + readonly inspect?: ( + sessionId: SessionId, + signal?: AbortSignal, + ) => Promise + readonly borrowSession?: ( + sessionId: SessionId, + signal?: AbortSignal, + ) => Promise +} + +/** Add the preparation-backed point-read contract to compact persistence doubles. */ +export function testSessionPersistence( + ctx: Context, + persistence: LegacyTestPersistence, +): LegacyTestPersistence { + if (persistence.borrowSession !== undefined) return persistence + return { + ...persistence, + borrowSession: async (sessionId, signal) => { + signal?.throwIfAborted() + const inspection = await persistence.inspect?.(sessionId, signal) + signal?.throwIfAborted() + if (inspection === undefined) throw new SessionPersistenceNotFoundError(sessionId) + try { + const preparedSession = ctx.sessions.prepare(inspection.meta.id, { + seed: [...inspection.events], + meta: inspection.meta, + seedSource: 'persistence', + }) + return { + source: 'prepared', + inspection: { + meta: preparedSession.header, + events: Object.freeze([...inspection.events]), + }, + revision: SessionPersistenceRevision(`test:${sessionId}:${String(preparedSession.seq)}`), + preparedSession, + [Symbol.dispose]: () => {}, + } + } catch (error: unknown) { + throw new SessionPersistenceCorruptionError( + `test session "${sessionId}" failed validation: ${String(error)}`, + { cause: error }, + ) + } + }, + } +} + +/** Concrete point-read query used by Session Controller tests that do not exercise search. */ +class TestSessionQuery extends SessionQueryEngine { + override searchSessions(): Promise { + return Promise.reject(new Error('session search is not configured in this test')) + } + + override searchEvents(): Promise { + return Promise.reject(new Error('event search is not configured in this test')) + } +} + +/** Install the required projection and point-query services for direct controller tests. */ +export function installSessionReadTestServices(ctx: Context): void { + if (ctx.get('sessionProjections') === undefined) new SessionProjectionRegistry(ctx) + if (ctx.get('sessionQuery') === undefined) new TestSessionQuery(ctx) +} + +function installControllers( + ctx: Context, + defaults: TestSessionRemoteDefaults, +): SessionController { + const found = installed.get(ctx) + if (found !== undefined) return found + + if (ctx.get('typert') === undefined) { + const dispose = (): void => {} + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + } as never) + } + if (ctx.get('agentDefaultModel') === undefined) { + ctx.provide('agentDefaultModel', { + currentSelection: defaults.defaultModelSelection, + saveSelection: async (selection: AgentModelSelection) => { + await defaults.saveDefaultModelSelection?.(selection) + }, + } as never) + } + if (ctx.get('llm') === undefined) { + ctx.provide('llm', { + listProviders: () => { + const selection = defaults.defaultModelSelection() + return [{ id: selection.provider, name: selection.provider }] + }, + } as never) + } + installSessionReadTestServices(ctx) + const cwd = vi.spyOn(process, 'cwd').mockReturnValue(defaults.cwd) + let controller: SessionController + try { + controller = new SessionController(ctx, defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }) + } finally { + cwd.mockRestore() + } + installed.set(ctx, controller) + return controller +} + +/** Build or return the production Session Controller for a direct unit harness. */ +export function createSessionTestController( + ctx: Context, + defaults: TestSessionRemoteDefaults, +): SessionController { + return installControllers(ctx, defaults) +} + +function remoteResult( + operation: () => T | Promise, + signal?: AbortSignal, +): Promise> { + return Promise.resolve() + .then(operation) + .then(value => ({ ok: true as const, value })) + .catch((error: unknown) => ({ + ok: false as const, + error: signal?.aborted === true + ? { code: 'cancelled', message: 'request was aborted', details: {} } + : error instanceof TypertRemoteFailure + ? error.failure + : { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + }, + })) +} + +/** Build the generated Session Remote's unary result semantics without a carrier. */ +export function createSessionTestRemote( + ctx: Context, + defaults: TestSessionRemoteDefaults, +): TestSessionRemote { + const direct = createSessionTestController(ctx, defaults) + return { + list: (request, signal = new AbortController().signal) => remoteResult( + () => direct.list(request, signal), + signal, + ), + search: (request, signal = new AbortController().signal) => remoteResult( + () => direct.search(request, signal), + signal, + ), + create: request => remoteResult(() => direct.create(request)), + selectModel: request => remoteResult(() => direct.selectModel(request)), + rename: request => remoteResult(() => direct.rename(request)), + fork: request => remoteResult(() => direct.fork(request)), + prompt: (request, signal = new AbortController().signal) => remoteResult( + () => direct.prompt(request, signal), + signal, + ), + attachment: request => remoteResult(() => direct.attachment(request)), + updateQueue: request => remoteResult(() => direct.updateQueue(request)), + cancel: request => remoteResult(() => direct.cancel(request)), + page: (request, signal = new AbortController().signal) => remoteResult( + () => direct.page(request, signal), + signal, + ), + follow: (request, signal = new AbortController().signal) => direct.follow(request, signal), + control: (signal = new AbortController().signal) => direct.control(signal), + } +} diff --git a/packages/client/runtime/tests/time-zone.client.spec.ts b/packages/api/session-controller/tests/time-zone.client.spec.ts similarity index 92% rename from packages/client/runtime/tests/time-zone.client.spec.ts rename to packages/api/session-controller/tests/time-zone.client.spec.ts index d96c9476c1..983dabc20c 100644 --- a/packages/client/runtime/tests/time-zone.client.spec.ts +++ b/packages/api/session-controller/tests/time-zone.client.spec.ts @@ -5,7 +5,7 @@ afterEach(() => { vi.restoreAllMocks() }) -describe('browser time zone', () => { +describe('Session Controller browser time zone', () => { it('returns the runtime-resolved zone', () => { expect(resolvedClientTimeZone()).toBe( new Intl.DateTimeFormat().resolvedOptions().timeZone, diff --git a/packages/api/session-controller/tests/transport.client.spec.ts b/packages/api/session-controller/tests/transport.client.spec.ts new file mode 100644 index 0000000000..1ad1d4e858 --- /dev/null +++ b/packages/api/session-controller/tests/transport.client.spec.ts @@ -0,0 +1,381 @@ +import { describe, expect, it, vi } from 'vitest' +import { + RemoteStream, + RemoteStreamCarrierError, + RemoteStreamError, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import { + createSessionControlStream, + SessionEventStream, + sessionStreamFailure, + type SessionJournalChange, + type SessionRemote, +} from '../src/client/index.ts' +import type { + SessionAddress, + SessionControlFrame, + SessionEventEntry, + SessionFollowFrame, + SessionFollowRequest, + SessionHistoryRecord, + SessionPage, + SessionPageRequest, +} from '../src/types.ts' + +type SessionTransportRemote = Pick + +const ADDRESS: SessionAddress = { kind: 'session', sessionId: 'session-1' as never } +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +function entry(seq: number): SessionEventEntry { + return { type: 'event', event: { type: 'turn/start', seq, time: seq, data: { turn: seq } } } +} + +function chunks(seq0: number): SessionHistoryRecord { + return { + type: 'chunks', + event: { + type: 'chunkrow/text-chunks', + seq: seq0, + time: seq0, + data: { turn: 1, step: 1, index: 0, texts: ['a', 'b', 'c'], dt: [1, 1] }, + }, + } +} + +function page(records: readonly SessionHistoryRecord[], hasMore = false): SessionPage { + return { records, hasMore } +} + +function snapshot( + cursor: number, + records: readonly SessionHistoryRecord[], + hasMore = false, +): SessionFollowFrame { + return { + type: 'snapshot', + header: { + version: 0, + id: ADDRESS.kind === 'session' ? ADDRESS.sessionId : ADDRESS.childSessionId, + createdAt: 0, + }, + cursor, + records, + hasMore, + projections: { asOfSeq: cursor, values: {} }, + } +} + +function sessionClient(remote: SessionTransportRemote) { + return { + session: remote as SessionRemote, + $stream: (options: RemoteStreamOptions) => ( + new RemoteStream(AVAILABLE_CONNECTION, options) + ), + } +} + +interface FollowGeneration { + readonly frames: readonly SessionFollowFrame[] + readonly terminal?: Error + readonly hold?: boolean + readonly waitAfterFrames?: Promise +} + +class ScriptedSessionRemote implements SessionTransportRemote { + readonly followRequests: SessionFollowRequest[] = [] + readonly pageRequests: SessionPageRequest[] = [] + readonly signals: AbortSignal[] = [] + + constructor( + private readonly generations: FollowGeneration[], + private readonly pages: RemoteResult[], + private readonly controlFrames: readonly SessionControlFrame[] = [], + private readonly holdControl = true, + ) {} + + async *follow(request: SessionFollowRequest, signal = new AbortController().signal): AsyncIterable { + const generation = this.generations.shift() + if (generation === undefined) throw new Error('no scripted Session generation') + this.followRequests.push(request) + this.signals.push(signal) + for (const frame of generation.frames) yield frame + await generation.waitAfterFrames + if (generation.terminal !== undefined) throw generation.terminal + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } + + page(request: SessionPageRequest): Promise> { + this.pageRequests.push(request) + const result = this.pages.shift() + if (result === undefined) throw new Error('no scripted Session page') + return Promise.resolve(result) + } + + async *control(signal = new AbortController().signal): AsyncIterable { + for (const frame of this.controlFrames) yield frame + if (this.holdControl && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + } + } +} + +describe('Session Client stream adapters', () => { + it('validates a packed logical range before publishing one compact Client entry', async () => { + const row = chunks(1) + const remote = new ScriptedSessionRemote( + [{ frames: [snapshot(4, [entry(0), row, entry(4)]), entry(5)], hold: true }], + [], + ) + const changes: SessionJournalChange[] = [] + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + failed: vi.fn(), + }) + + await stream.open({}) + await vi.waitFor(() => { expect(changes).toHaveLength(2) }) + + expect(changes[0]).toMatchObject({ + type: 'replace', + entries: [ + entry(0), + row, + entry(4), + ], + }) + expect(changes[0]?.type === 'replace' ? changes[0].entries[1] : undefined).toBe(row) + expect(changes[1]).toEqual({ type: 'append', entry: entry(5) }) + await stream.dispose() + }) + + it('rejects a packed record emitted by the live follow path', async () => { + const failed = vi.fn() + const remote = new ScriptedSessionRemote( + [{ frames: [snapshot(-1, []), chunks(0) as SessionFollowFrame], hold: true }], + [], + ) + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: vi.fn(), + failed, + }) + + await stream.open({}) + await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) + expect(failed.mock.calls[0]?.[0]).toMatchObject({ + message: 'session live stream emitted a packed history record', + }) + await stream.dispose() + }) + + it('binds an event journal to one address and publishes replace, append, and prepend changes', async () => { + const remote = new ScriptedSessionRemote( + [{ + frames: [ + snapshot(3, [entry(2), entry(3)], true), + entry(3), + entry(4), + ], + hold: true, + }], + [ + { ok: true, value: page([entry(0), entry(1)], false) }, + ], + ) + const changes: SessionJournalChange[] = [] + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + failed: vi.fn(), + }) + + await stream.open({ maxMessages: 50 }) + await vi.waitFor(() => { expect(changes).toHaveLength(2) }) + await stream.prepend({ beforeSeq: 2, maxMessages: 50 }) + + expect(remote.followRequests).toEqual([{ address: ADDRESS, maxMessages: 50 }]) + expect(remote.pageRequests).toEqual([ + { address: ADDRESS, throughSeq: 4, beforeSeq: 2, maxMessages: 50 }, + ]) + expect(changes).toMatchObject([ + { type: 'replace', entries: [entry(2), entry(3)], hasMore: true }, + { type: 'append', entry: entry(4) }, + { type: 'prepend', entries: [entry(0), entry(1)], hasMore: false }, + ]) + await stream.dispose() + expect(remote.signals[0]?.aborted).toBe(true) + }) + + it('replaces the retained window from each reconnect snapshot', async () => { + const lost = new RemoteStreamCarrierError('lost') + const remote = new ScriptedSessionRemote( + [ + { + frames: [snapshot(1, [entry(0), entry(1)]), entry(2)], + terminal: lost, + }, + { frames: [snapshot(4, [entry(0), entry(1), entry(2), entry(3), entry(4)])], hold: true }, + ], + [], + ) + const changes: SessionJournalChange[] = [] + const carrierFailed = vi.fn() + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + carrierFailed, + failed: vi.fn(), + }) + + await stream.open({ maxMessages: 50 }) + await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) + + expect(remote.followRequests).toEqual([ + { address: ADDRESS, maxMessages: 50 }, + { address: ADDRESS, maxMessages: 50 }, + ]) + expect(remote.pageRequests).toEqual([]) + expect(changes.map(change => change.type)).toEqual(['replace', 'append', 'replace']) + expect(carrierFailed).toHaveBeenCalledWith(lost) + await stream.dispose() + }) + + it('repairs a resumed event stream without an optional message limit', async () => { + const finish = Promise.withResolvers() + const remote = new ScriptedSessionRemote( + [ + { + frames: [snapshot(0, [entry(0)])], + waitAfterFrames: finish.promise, + terminal: new RemoteStreamCarrierError('lost'), + }, + { frames: [snapshot(1, [entry(0), entry(1)])], hold: true }, + ], + [], + ) + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: vi.fn(), + failed: vi.fn(), + }) + + await stream.open({}) + finish.resolve(undefined) + await vi.waitFor(() => { expect(remote.followRequests).toHaveLength(2) }) + expect(remote.followRequests).toEqual([{ address: ADDRESS }, { address: ADDRESS }]) + expect(remote.pageRequests).toEqual([]) + await stream.dispose() + }) + + it('repairs a live gap without adding an absent message limit', async () => { + const remote = new ScriptedSessionRemote( + [{ frames: [snapshot(0, [entry(0)]), entry(2)], hold: true }], + [{ ok: true, value: page([entry(0), entry(1), entry(2)]) }], + ) + const changes: SessionJournalChange[] = [] + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: (change) => { changes.push(change) }, + failed: vi.fn(), + }) + + await stream.open({}) + await vi.waitFor(() => { expect(changes).toHaveLength(2) }) + expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: 2 }]) + await stream.dispose() + }) + + it('turns a pagination failure into a typed stream failure', async () => { + const failure = { code: 'session-not-found', message: 'missing', details: { sessionId: 'session-1' } } as const + const remote = new ScriptedSessionRemote( + [{ frames: [snapshot(-1, [])], hold: true }], + [{ ok: false, error: failure }], + ) + const stream = new SessionEventStream(sessionClient(remote), ADDRESS, { + publish: vi.fn(), + failed: vi.fn(), + }) + + await stream.open({}) + await expect(stream.prepend({})).rejects.toBeInstanceOf(RemoteStreamError) + await expect(stream.open({})).rejects.toThrow('already opened') + expect(sessionStreamFailure(new RemoteStreamError(failure.code, failure.message, failure.details))) + .toEqual(failure) + expect(sessionStreamFailure(new Error('local'))).toBeUndefined() + expect(remote.signals[0]?.aborted).toBe(false) + expect(remote.pageRequests).toEqual([{ address: ADDRESS, throughSeq: -1 }]) + await stream.dispose() + expect(remote.signals[0]?.aborted).toBe(true) + }) + + it('maps the Host-wide control baseline and deltas into one snapshot stream', async () => { + const baseline: SessionControlFrame = { + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + } + const update: SessionControlFrame = { + type: 'queue', sessionId: 'session-1' as never, items: [], + } + const remote = new ScriptedSessionRemote([], [], [baseline, update]) + const accept = vi.fn<(frame: SessionControlFrame) => void>() + const stream = createSessionControlStream(sessionClient(remote), { + accept, + failed: vi.fn(), + }) + + stream.start() + stream.start() + await vi.waitFor(() => { expect(accept).toHaveBeenCalledTimes(2) }) + expect(accept.mock.calls.map(([frame]) => frame)).toEqual([baseline, update]) + await stream.dispose() + await stream.dispose() + }) + + it('classifies control streams that end before and after their opening baseline', async () => { + const beforeFailed = vi.fn() + const before = createSessionControlStream( + sessionClient(new ScriptedSessionRemote([], [], [], false)), + { accept: vi.fn(), failed: beforeFailed }, + ) + before.start() + await vi.waitFor(() => { expect(beforeFailed).toHaveBeenCalledOnce() }) + expect(beforeFailed.mock.calls[0]?.[0]).toMatchObject({ + message: 'session control stream ended before its opening snapshot', + }) + await before.dispose() + + const baseline: SessionControlFrame = { + type: 'baseline', + value: { queues: {}, jobs: {}, projections: {} }, + } + const carrierFailed = vi.fn() + const failed = vi.fn() + const afterRemote = new ScriptedSessionRemote([], [], [baseline], false) + const after = createSessionControlStream(sessionClient(afterRemote), { + accept: vi.fn(), + carrierFailed: (error) => { + carrierFailed(error) + void after.dispose() + }, + failed, + }) + after.start() + await vi.waitFor(() => { expect(carrierFailed).toHaveBeenCalledOnce() }) + expect(carrierFailed.mock.calls[0]?.[0]).toMatchObject({ + message: 'session control stream ended without a terminal result', + }) + expect(failed).not.toHaveBeenCalled() + await after.dispose() + }) +}) diff --git a/packages/api/session-controller/tests/transport.host.spec.ts b/packages/api/session-controller/tests/transport.host.spec.ts new file mode 100644 index 0000000000..8461d99a24 --- /dev/null +++ b/packages/api/session-controller/tests/transport.host.spec.ts @@ -0,0 +1,657 @@ +import { Context } from '@deepseek-ai/cordis' +import { createScope } from '@deepseek-ai/dsh-scope' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' +import type { SessionObservation } from '@deepseek-ai/dsh-session-query' +import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' +import { subagentIdentityProjectionDefinition } from '@deepseek-ai/dsh-subagent/src/projection.ts' +import { describe, expect, it, vi } from 'vitest' +import { SessionHistoryController } from '../src/history.ts' +import { installSessionReadTestServices, testSessionPersistence } from './test-remote.ts' + +const signal = (): AbortSignal => new AbortController().signal + +function append( + session: Session, + type: string, + data: unknown, + options?: { readonly surfaceOp?: unknown; readonly sourceEventSeqs?: readonly number[] }, +): SessionEvent { + return (session.append as unknown as ( + eventType: string, + eventData: unknown, + eventOptions?: unknown, + ) => SessionEvent)(type, data, options) +} + +function event(type: string, seq: number, data: unknown = {}): SessionEvent { + return { + type, + seq, + time: seq + 1, + data, + } as SessionEvent +} + +function cold( + ctx: Context, + header: SessionHeader, + events: readonly SessionEvent[], +): void { + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.resolve({ meta: header, events }), + }) as never) +} + +interface Deferred { + readonly promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +async function setup(): Promise<{ ctx: Context; transport: SessionHistoryController }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + ctx.sessionProjections.register(subagentIdentityProjectionDefinition) + const transport = new SessionHistoryController(ctx, (observation) => { observation[Symbol.dispose]() }) + return { ctx, transport } +} + +describe('SessionHistoryController', () => { + it('opens at the current cursor and follows later events from an ordinary Session', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('ordinary'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + const abort = new AbortController() + const iterator = transport.follow( + { address: { kind: 'session', sessionId: session.id } }, + abort.signal, + )[Symbol.asyncIterator]() + + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + expect(await iterator.next()).toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'turn/end', seq: 1 } }, + }) + + const page = await transport.page( + { address: { kind: 'session', sessionId: session.id }, throughSeq: 1 }, + new AbortController().signal, + ) + expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1]) + + abort.abort() + expect(await iterator.next()).toMatchObject({ done: true }) + }) + + it('ends active followers when the owning Controller unloads', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + let transport!: SessionHistoryController + const owner = ctx.plugin(Object.assign( + (inner: Context) => { + transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + }, + { inject: ['sessions', 'sessionQuery'] }, + )) + await owner.await() + const session = ctx.sessions.create(SessionId('controller-unload'), { meta: { cwd: '/workspace' } }) + const iterator = transport.follow( + { address: { kind: 'session', sessionId: session.id } }, + new AbortController().signal, + )[Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'snapshot', cursor: -1 }, + }) + const pending = iterator.next() + await owner.dispose() + await expect(pending).resolves.toEqual({ done: true, value: undefined }) + await ctx.fiber.dispose() + }) + + it('reconnects with a complete replacement snapshot before later live events', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('resume'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + session.append('turn/start', { turn: 2 }) + const abort = new AbortController() + const iterator = transport.follow({ + address: { kind: 'session', sessionId: session.id }, + }, abort.signal)[Symbol.asyncIterator]() + + expect(await iterator.next()).toMatchObject({ + done: false, + value: { + type: 'snapshot', + cursor: 2, + records: [ + { type: 'event', event: { seq: 0 } }, + { type: 'event', event: { seq: 1 } }, + { type: 'event', event: { seq: 2 } }, + ], + }, + }) + session.append('turn/end', { turn: 2, reason: { kind: 'completed' } }) + expect(await iterator.next()).toMatchObject({ done: false, value: { type: 'event', event: { seq: 3 } } }) + + abort.abort() + expect(await iterator.next()).toMatchObject({ done: true }) + }) + + it('subscribes before a cold read and ignores unrelated and replayed buffered events', async () => { + const { ctx, transport } = await setup() + const sessionId = SessionId('cold-race') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const inspected = deferred<{ meta: SessionHeader; events: readonly SessionEvent[] }>() + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + inspect: () => inspected.promise, + }) as never) + const abort = new AbortController() + const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + const opening = iterator.next() + + ctx.emit('session/event', { + id: SessionId('unrelated'), events: [event('fixture/other', 0)], + } as unknown as Session, event('fixture/other', 0)) + ctx.emit('session/event', { + id: sessionId, events: [event('fixture/start', 0)], + } as unknown as Session, event('fixture/start', 0)) + inspected.resolve({ meta: header, events: [event('fixture/start', 0)] }) + await expect(opening).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) + + const waiting = iterator.next() + abort.abort() + await expect(waiting).resolves.toMatchObject({ done: true }) + }) + + it('buffers creation while the opening observation is unresolved', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('created-during-observation') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const observed = deferred() + ctx.provide('sessionQuery', { observeSession: () => observed.promise } as never) + const transport = new SessionHistoryController(ctx, vi.fn()) + const abort = new AbortController() + const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + const opening = iterator.next() + + const attached = ctx.sessions.create(sessionId, { meta: header, seed: [event('fixture/seed', 0)] }) + observed.resolve({ + source: 'live', + header: attached.header, + events: attached.events, + cursor: attached.seq - 1, + projections: { asOfSeq: attached.seq - 1, values: {} }, + retain: vi.fn(), + [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation) + await expect(opening).resolves.toMatchObject({ + done: false, + value: { + type: 'snapshot', + cursor: 1, + records: [ + { type: 'event', event: { seq: 0 } }, + { type: 'event', event: { seq: 1 } }, + ], + }, + }) + expect(attached.id).toBe(sessionId) + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) + + it('bridges the unpublished end-seed boundary when a cold source attaches', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + installSessionReadTestServices(ctx) + let transport!: SessionHistoryController + let agentCtx!: Context + await ctx.plugin(Object.assign( + (inner: Context) => { + transport = new SessionHistoryController(inner, (observation) => { observation[Symbol.dispose]() }) + }, + { inject: ['sessions', 'sessionQuery'] }, + )) + await ctx.plugin(Object.assign( + (inner: Context) => { agentCtx = createScope(inner, { name: 'agent' }).ctx }, + { inject: ['sessions'] }, + )) + const sessionId = SessionId('cold-attach') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const seed = [event('fixture/start', 0)] + cold(ctx, header, seed) + agentCtx.on('session/created', (session) => { + if (session.id !== sessionId) return + append(session, 'fixture/setup-one', {}) + append(session, 'fixture/setup-two', {}) + }) + const abort = new AbortController() + const iterator = transport.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) + agentCtx.sessions.create(SessionId('unrelated-created'), { meta: { cwd: '/workspace' } }) + const attached = agentCtx.sessions.prepare(sessionId, { meta: header, seed }) + agentCtx.sessions.enter(attached) + agentCtx.sessions.announce(attached) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'session/end-seed', seq: 1 } }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'fixture/setup-one', seq: 2 } }, + }) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'fixture/setup-two', seq: 3 } }, + }) + append(attached, 'fixture/live', {}) + await expect(iterator.next()).resolves.toMatchObject({ + done: false, + value: { type: 'event', event: { type: 'fixture/live', seq: 4 } }, + }) + + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) + + it('rejects gaps in replayed and live event sequences', async () => { + const replay = await setup() + const replayId = SessionId('replay-gap') + const replayHeader = { version: 0, id: replayId, createdAt: 1, cwd: '/workspace' } + cold(replay.ctx, replayHeader, [event('fixture/start', 0), event('fixture/gap', 2)]) + const replayed = replay.transport.follow({ + address: { kind: 'session', sessionId: replayId }, + }, signal())[Symbol.asyncIterator]() + await expect(replayed.next()).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) + + const live = await setup() + const session = live.ctx.sessions.create(SessionId('live-gap'), { meta: { cwd: '/workspace' } }) + append(session, 'fixture/start', {}) + live.ctx.provide('agents', { get: () => ({ id: session.id }) } as never) + const followed = live.transport.follow({ + address: { kind: 'session', sessionId: session.id }, + }, signal())[Symbol.asyncIterator]() + await expect(followed.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: 0 } }) + const skipped = event('fixture/skipped', 1) + const gap = event('fixture/gap', 2) + live.ctx.emit('session/event', { + id: session.id, + events: [event('fixture/start', 0), skipped, gap], + } as unknown as Session, gap) + await expect(followed.next()).rejects.toMatchObject({ failure: { code: 'internal' } }) + }) + + it('opens an empty source at cursor -1', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('empty-follow'), { meta: { cwd: '/workspace' } }) + const abort = new AbortController() + const iterator = transport.follow({ + address: { kind: 'session', sessionId: session.id }, + }, abort.signal)[Symbol.asyncIterator]() + await expect(iterator.next()).resolves.toMatchObject({ done: false, value: { type: 'snapshot', cursor: -1 } }) + await expect(transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: -1, + }, signal())).resolves.toMatchObject({ records: [], hasMore: false }) + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + }) + + it('publishes an empty projection baseline when the query has no registry', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('projectionless-follow') + const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve({ + source: 'live', header: meta, events: [], cursor: -1, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + } satisfies SessionObservation), + } as never) + const history = new SessionHistoryController(ctx, vi.fn()) + const abort = new AbortController() + const iterator = history.follow({ address: { kind: 'session', sessionId } }, abort.signal) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ + value: { type: 'snapshot', projections: { asOfSeq: -1, values: {} } }, + }) + abort.abort() + await expect(iterator.next()).resolves.toMatchObject({ done: true }) + await ctx.fiber.dispose() + }) + + it('disposes a retained promotion when background activation rejects synchronously', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const sessionId = SessionId('promotion-failure') + const meta = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const disposePromotion = vi.fn() + const promotion = { + source: 'prepared', header: meta, events: [], cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), [Symbol.dispose]: disposePromotion, + } as unknown as SessionObservation + const source = { + ...promotion, + retain: () => promotion, + [Symbol.dispose]: vi.fn(), + } as SessionObservation + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve(source), + } as never) + const history = new SessionHistoryController(ctx, () => { throw new Error('activation failed') }) + const iterator = history.follow({ address: { kind: 'session', sessionId } }, signal()) + [Symbol.asyncIterator]() + + await expect(iterator.next()).resolves.toMatchObject({ value: { type: 'snapshot' } }) + await expect(iterator.next()).rejects.toThrow('activation failed') + expect(disposePromotion).toHaveBeenCalledOnce() + await ctx.fiber.dispose() + }) + + it('requires the durable parent and mode for a direct subagent address', async () => { + const { ctx, transport } = await setup() + const parentSessionId = SessionId('parent') + const childSessionId = SessionId('child') + ctx.sessions.create(parentSessionId, { meta: { cwd: '/workspace' } }) + const child = ctx.sessions.create(childSessionId, { + meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId }, + }) + child.append('subagent/descriptor', snapshotSubagentDescriptor({ + mode: 'continuable', + provider: 'test', + label: 'child', + })) + const signal = new AbortController().signal + + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, + throughSeq: 0, + }, signal)).resolves.toMatchObject({ + records: [{ type: 'event', event: { type: 'subagent/descriptor' } }], + }) + await expect(transport.page({ + address: { + kind: 'subagent', + parentSessionId: SessionId('other-parent'), + childSessionId, + mode: 'continuable', + }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + await expect(transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'one-shot' }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + await expect(transport.page({ + address: { kind: 'session', sessionId: childSessionId }, + throughSeq: 0, + }, signal)).rejects.toMatchObject({ failure: { code: 'agent-busy' } }) + }) + + it('preserves a cold inspection failure for the Gateway error branch', async () => { + const { ctx, transport } = await setup() + const sessionId = SessionId('corrupt-cold') + const failure = new Error('cold log is corrupt') + const header = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([header]), + inspect: () => Promise.reject(failure), + }) as never) + + await expect(transport.page({ + address: { kind: 'session', sessionId }, + throughSeq: -1, + }, new AbortController().signal)).rejects.toMatchObject({ + code: 'SESSION_QUERY_PERSISTENCE_FAILED', + cause: failure, + }) + }) + + it('rejects malformed page and follow cursors at the service boundary', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('validation'), { meta: { cwd: '/workspace' } }) + const address = { kind: 'session' as const, sessionId: session.id } + for (const request of [ + { address, throughSeq: -2 }, + { address, throughSeq: 0.5 }, + { address, throughSeq: -1, beforeSeq: -1 }, + { address, throughSeq: -1, beforeSeq: 1.5 }, + { address, throughSeq: -1, maxMessages: 0 }, + { address, throughSeq: -1, maxMessages: 1.5 }, + ]) { + await expect(transport.page(request, signal())).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + } + await expect(transport.page({ address, throughSeq: 0 }, signal())) + .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + + const corrupt = await setup() + const corruptId = SessionId('missing-through-seq') + cold( + corrupt.ctx, + { version: 0, id: corruptId, createdAt: 1, cwd: '/workspace' }, + [event('fixture/start', 0), event('fixture/gap', 2)], + ) + await expect(corrupt.transport.page({ + address: { kind: 'session', sessionId: corruptId }, throughSeq: 1, + }, signal())).rejects.toMatchObject({ code: 'SESSION_QUERY_CORRUPT_SESSION' }) + for (const maxMessages of [0, 0.5]) { + const iterator = transport.follow({ address, maxMessages }, signal())[Symbol.asyncIterator]() + await expect(iterator.next()).rejects.toMatchObject({ failure: { code: 'bad-request' } }) + } + }) + + it('reports missing ordinary and subagent sources without fabricating inspection failures', async () => { + const { ctx, transport } = await setup() + const ordinary = { kind: 'session' as const, sessionId: SessionId('missing') } + await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + + const inspect = vi.fn(() => Promise.resolve(undefined)) + ctx.provide('sessionPersistence', testSessionPersistence(ctx, { + list: () => Promise.resolve([]), + inspect, + }) as never) + await expect(transport.page({ address: ordinary, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + await expect(transport.page({ + address: { + kind: 'subagent', + parentSessionId: SessionId('parent'), + childSessionId: SessionId('missing-child'), + mode: 'continuable', + }, + throughSeq: -1, + }, signal())).rejects.toMatchObject({ failure: { code: 'subagent-not-found' } }) + expect(inspect).toHaveBeenCalledTimes(2) + }) + + it('rejects incomplete cold metadata before serving a source', async () => { + const first = await setup() + const sessionId = SessionId('incomplete') + const address = { kind: 'session' as const, sessionId } + const firstHeader = { version: 0, id: sessionId, createdAt: 1 } + first.ctx.provide('sessionPersistence', testSessionPersistence(first.ctx, { + list: () => Promise.resolve([firstHeader]), + inspect: () => Promise.resolve({ meta: firstHeader, events: [] }), + }) as never) + await expect(first.transport.page({ address, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + + const second = await setup() + const listed = { version: 0, id: sessionId, createdAt: 1, cwd: '/workspace' } + const inspected = { version: 0, id: sessionId, createdAt: 1 } + second.ctx.provide('sessionPersistence', testSessionPersistence(second.ctx, { + list: () => Promise.resolve([listed]), + inspect: () => Promise.resolve({ meta: inspected, events: [] }), + }) as never) + await expect(second.transport.page({ address, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + }) + + it('serves cold ordinary history and validates every durable subagent descriptor state', async () => { + const ordinaryBench = await setup() + const ordinaryId = SessionId('cold-ordinary') + const ordinaryHeader = { version: 0, id: ordinaryId, createdAt: 1, cwd: '/workspace' } + cold(ordinaryBench.ctx, ordinaryHeader, [event('turn/start', 0, { turn: 1 })]) + await expect(ordinaryBench.transport.page({ + address: { kind: 'session', sessionId: ordinaryId }, + throughSeq: 0, + }, signal())).resolves.toMatchObject({ + records: [{ type: 'event', event: { seq: 0 } }], + }) + + const parentSessionId = SessionId('cold-parent') + const childSessionId = SessionId('cold-child') + const childHeader = { + version: 0, + id: childSessionId, + createdAt: 1, + cwd: '/workspace', + origin: 'subagent' as const, + parentSession: parentSessionId, + } + const childAddress = { + kind: 'subagent' as const, + parentSessionId, + childSessionId, + mode: 'continuable' as const, + } + const missing = await setup() + cold(missing.ctx, childHeader, []) + await expect(missing.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + + const corrupt = await setup() + cold(corrupt.ctx, childHeader, [event('subagent/descriptor', 0, { version: 'bad' })]) + await expect(corrupt.transport.page({ address: childAddress, throughSeq: 0 }, signal())) + .rejects.toMatchObject({ failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'corrupt' } } }) + + const ordinaryChild = await setup() + const { origin: _origin, ...ordinaryChildHeader } = childHeader + cold(ordinaryChild.ctx, ordinaryChildHeader, []) + await expect(ordinaryChild.transport.page({ address: childAddress, throughSeq: -1 }, signal())) + .rejects.toMatchObject({ failure: { code: 'subagent-unauthorized' } }) + }) + + it('reports an unavailable descriptor when an observed child has no projection value', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + const parentSessionId = SessionId('missing-projection-parent') + const childSessionId = SessionId('missing-projection-child') + const meta: SessionHeader = { + version: 0, + id: childSessionId, + createdAt: 1, + cwd: '/workspace', + origin: 'subagent', + parentSession: parentSessionId, + } + ctx.provide('sessionQuery', { + observeSession: () => Promise.resolve({ + source: 'live', header: meta, events: [], cursor: -1, + projections: { asOfSeq: -1, values: {} }, + retain: vi.fn(), [Symbol.dispose]: vi.fn(), + } as unknown as SessionObservation), + } as never) + const history = new SessionHistoryController(ctx, vi.fn()) + + await expect(history.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, + throughSeq: -1, + }, signal())).rejects.toMatchObject({ + failure: { code: 'subagent-catalog-diagnostic', details: { reason: 'unsupported' } }, + }) + await ctx.fiber.dispose() + }) + + it('keeps pages projection-free and computes projections only for child authorization', async () => { + const ordinary = await setup() + const session = ordinary.ctx.sessions.create(SessionId('projected'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + const ordinarySnapshot = vi.spyOn(ordinary.ctx.sessionProjections, 'snapshot') + const ordinaryPage = await ordinary.transport.page({ + address: { kind: 'session', sessionId: session.id }, + throughSeq: 0, + }, signal()) + expect('projections' in ordinaryPage).toBe(false) + expect(ordinarySnapshot).not.toHaveBeenCalled() + + const child = await setup() + const parentSessionId = SessionId('projection-parent') + const childSessionId = SessionId('projection-child') + const childSession = child.ctx.sessions.create(childSessionId, { + meta: { cwd: '/workspace', origin: 'subagent', parentSession: parentSessionId }, + }) + childSession.append('subagent/descriptor', snapshotSubagentDescriptor({ + mode: 'continuable', provider: 'test', label: 'child', + })) + const childSnapshot = vi.spyOn(child.ctx.sessionProjections, 'snapshot') + const page = await child.transport.page({ + address: { kind: 'subagent', parentSessionId, childSessionId, mode: 'continuable' }, + throughSeq: 0, + }, signal()) + expect('projections' in page).toBe(false) + expect(childSnapshot).toHaveBeenCalledWith(childSession) + }) + + it('keeps message-aligned pagination contiguous across replacement provenance', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('pagination'), { meta: { cwd: '/workspace' } }) + session.append('turn/start', { turn: 1 }) + append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) + const firstReply = append(session, 'assistant/message', { turn: 1, step: 1, message: {} }, { surfaceOp: 'append' }) + append(session, 'user/message', { content: [], source: { kind: 'user' } }, { surfaceOp: 'append' }) + append(session, 'assistant/message', { turn: 1, step: 2, message: {} }, { surfaceOp: 'append' }) + const summary = append(session, 'fixture/summary', {}) + const replacement = append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { + surfaceOp: { op: 'replace', start: 1, end: 4 }, + sourceEventSeqs: [1, firstReply.seq, 3, 4, summary.seq], + }) + + const page = await transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, maxMessages: 2, + }, signal()) + expect(page.records.map(entry => entry.event.seq)) + .toEqual([3, 4, 5, replacement.seq]) + expect(page.hasMore).toBe(true) + const before = await transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: replacement.seq, beforeSeq: 3, maxMessages: 1, + }, signal()) + expect(before.records.map(entry => entry.event.seq)).toEqual([2]) + }) + + it('keeps cited source events in the page that owns their appended message', async () => { + const { ctx, transport } = await setup() + const session = ctx.sessions.create(SessionId('pagination-sources'), { meta: { cwd: '/workspace' } }) + const source = append(session, 'fixture/source', {}) + append(session, 'user/message', { content: [], source: { kind: 'plugin' } }, { + surfaceOp: 'append', sourceEventSeqs: [source.seq], + }) + + const page = await transport.page({ + address: { kind: 'session', sessionId: session.id }, throughSeq: 1, maxMessages: 1, + }, signal()) + expect(page.records.map(entry => entry.event.seq)).toEqual([0, 1]) + expect(page.hasMore).toBe(false) + }) + +}) diff --git a/packages/api/session-controller/tsconfig.client.json b/packages/api/session-controller/tsconfig.client.json new file mode 100644 index 0000000000..219893c980 --- /dev/null +++ b/packages/api/session-controller/tsconfig.client.json @@ -0,0 +1,31 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "include": [ + "src/client/**/*.ts", + "src/types.ts", + "src/remote-events.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../attachment/attachment" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../session/session-projection" }, + { "path": "../../session/session-title" }, + { "path": "../../subagent/subagent" }, + { "path": "../../util/brand" }, + { "path": "../../util/crypto" }, + { "path": "../../util/workspace-path" }, + { "path": "../../workspace/workspace" }, + { "path": "../../typert/protocol" } + ] +} diff --git a/packages/api/session-controller/tsconfig.host.json b/packages/api/session-controller/tsconfig.host.json new file mode 100644 index 0000000000..d3256426e2 --- /dev/null +++ b/packages/api/session-controller/tsconfig.host.json @@ -0,0 +1,44 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/remote-events.ts", + "src/agent.ts", + "src/catalog.ts", + "src/commands.ts", + "src/control.ts", + "src/history.ts", + "src/list.ts", + "src/model-selection-projection.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../../vendor/schemastery" }, + { "path": "../../core/agent" }, + { "path": "../../core/agent-default-model" }, + { "path": "../../core/scope" }, + { "path": "../../core/session" }, + { "path": "../../attachment/attachment" }, + { "path": "../../interaction/permission-presets" }, + { "path": "../../jobs/jobs" }, + { "path": "../../llm/llm" }, + { "path": "../../preset/agent-presets" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../session/session-persistence" }, + { "path": "../../session/session-projection" }, + { "path": "../../session/session-projection-cache" }, + { "path": "../../session/session-title" }, + { "path": "../../session-query/session-query" }, + { "path": "../../subagent/subagent" }, + { "path": "../../typert/protocol" }, + { "path": "../../typert/registry" }, + { "path": "../../workspace/workspace" } + ] +} diff --git a/packages/api/session-controller/tsconfig.json b/packages/api/session-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/session-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/session-controller/tsdown.config.ts b/packages/api/session-controller/tsdown.config.ts new file mode 100644 index 0000000000..9ac9ebf59b --- /dev/null +++ b/packages/api/session-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-session-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/api/workspace-controller/README.i18n.yaml b/packages/api/workspace-controller/README.i18n.yaml new file mode 100644 index 0000000000..eee1d5bd5a --- /dev/null +++ b/packages/api/workspace-controller/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/api/workspace-controller/README.md +README.md: d2f89e6c9f0118be9150650c1c6dec859375f990 +README.zh.md: 3411ca0642df7fa7f8652d1863905868f54cf4bd diff --git a/packages/api/workspace-controller/README.md b/packages/api/workspace-controller/README.md new file mode 100644 index 0000000000..d2f89e6c9f --- /dev/null +++ b/packages/api/workspace-controller/README.md @@ -0,0 +1,56 @@ +--- +description: "Host and Client workspace control: mutate workspace navigation and follow its complete projection." +kind: "package-reference" +--- +# Workspace Controller + +English | [中文](README.zh.md) + +## Summary + +`@deepseek-ai/dsh-api-workspace-controller` owns the Host `ctx.workspaceController` service and the generated Client `ctx.remote.workspace` namespace. Its Remote methods create, rename, remove, and reorder Workspaces, reorder Sessions within a Workspace, archive Sessions from Workspace navigation, and follow the complete Workspace projection. Use it through API Gateway when a Client must change or follow Workspace navigation. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The Host controller serializes mutations whose correctness depends on current registry state and returns stable `WorkspaceError` values for expected failures. Its `follow()` stream synchronously attaches to durable Workspace changes, emits one complete baseline first, then emits ordered `upsert`, `remove`, `order`, and `archived` increments. A reconnect starts another generation with a replacement baseline, so consumers do not depend on receiving every increment while disconnected. + +The Client entry provides `ClientWorkspaceModel` and `createWorkspaceStateStream()`. The model owns Workspace rows, registry order, archived Session ids, unary mutation echoes, and stream/unary race resolution. A newer Host row wins by `updatedAt`; a committed stream order outranks an older unary response; a removed Workspace id cannot be resurrected by delayed data. The package exposes framework-neutral snapshots and subscriptions, leaving navigation policy and React hooks to the UI owner. + +----- + + +## Model Experience + +None, as Workspace organization is browser and Host control state and registers no prompt, tool, or session event. + +#### KV Cache effect + +No direct effect; Workspace mutations do not alter model requests. + +## Known Limitations and Deferred Work + + + +- `follow()` replaces the whole projection after reconnect and has no durable cursor or incremental catch-up protocol. +- Process-local deletion markers prevent delayed data from reviving a removed Workspace only for the lifetime of the Client model. + + + +### Dev Note + +

+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/api/workspace-controller/README.zh.md b/packages/api/workspace-controller/README.zh.md new file mode 100644 index 0000000000..3411ca0642 --- /dev/null +++ b/packages/api/workspace-controller/README.zh.md @@ -0,0 +1,56 @@ +--- +description: "Host 与 Client 工作区控制:修改工作区导航并跟随其完整投影。" +kind: "package-reference" +--- +# Workspace Controller + +[English](README.md) | 中文 + +## 概述 + +`@deepseek-ai/dsh-api-workspace-controller` 拥有 Host 的 `ctx.workspaceController` 服务和生成的 Client `ctx.remote.workspace` namespace。它的 Remote 方法负责创建、重命名、移除和重排 Workspace,在 Workspace 内重排 Session,从 Workspace 导航中归档 Session,以及跟随完整的 Workspace 投影。当 Client 必须修改或跟随 Workspace 导航时,请通过 API Gateway 使用它。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +Host 控制器会串行执行正确性取决于当前 registry 状态的变更,并为预期失败返回稳定的 `WorkspaceError` 值。它的 `follow()` 流会同步订阅持久 Workspace 变更,先发出一份完整 baseline,再按顺序发出 `upsert`、`remove`、`order` 和 `archived` 增量。重连会以替换 baseline 开始新一代,因此消费方不依赖收到断线期间的每个增量。 + +Client 入口提供 `ClientWorkspaceModel` 和 `createWorkspaceStateStream()`。该模型拥有 Workspace 行、registry 顺序、已归档 Session id、一元变更回声,以及流与一元调用的竞态处理。较新的 Host 行按 `updatedAt` 获胜;已提交的流顺序优先于较旧的一元响应;已经移除的 Workspace id 不会被延迟数据复活。该包公开与框架无关的快照和订阅,把导航策略与 React hook 留给 UI owner。 + +----- + + +## 模型体验 + +无,因为 Workspace 组织属于浏览器与 Host 控制状态,并且不注册提示词、工具或会话事件。 + +#### KV Cache 影响 + +无直接影响;Workspace 变更不会改变模型请求。 + +## 已知限制与延期工作 + + + +- `follow()` 在重连后替换完整投影,不提供持久 cursor 或增量追赶协议。 +- 进程本地删除标记只会在 Client 模型生命周期内阻止延迟数据复活已移除的 Workspace。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/api/workspace-controller/package.json b/packages/api/workspace-controller/package.json new file mode 100644 index 0000000000..29245fef86 --- /dev/null +++ b/packages/api/workspace-controller/package.json @@ -0,0 +1,96 @@ +{ + "name": "@deepseek-ai/dsh-api-workspace-controller", + "description": "Workspace Remote commands and reconnect-safe state transport", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/workspace-controller" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "external": [ + "@deepseek-ai/dsh-api-gateway/client" + ], + "inject": [ + "@deepseek-ai/dsh-api-gateway", + "@deepseek-ai/dsh-client-connection" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts" + ], + "license": "MIT", + "dependencies": { + "zod": "^4.4.3" + }, + "peerDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-api-gateway": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-typert-protocol": "workspace:^", + "@deepseek-ai/dsh-workspace": "workspace:^" + } +} diff --git a/packages/api/workspace-controller/src/client/index.ts b/packages/api/workspace-controller/src/client/index.ts new file mode 100644 index 0000000000..0e2c3c5fa7 --- /dev/null +++ b/packages/api/workspace-controller/src/client/index.ts @@ -0,0 +1,124 @@ +/** Workspace-specific adapter for the Gateway-owned snapshot stream lifecycle. */ + +import type { Context } from '@deepseek-ai/cordis' +import { + RemoteSnapshotStream, + RemoteStreamCarrierError, + type ClientRemote, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { WorkspaceFollowFrame, WorkspaceFollowIncrement } from '../types.ts' +import type { WorkspaceFollowSink, WorkspaceRemote } from './model.ts' +import { ClientWorkspaceModel } from './model.ts' +import { WorkspaceController } from './service.ts' + +export { ClientWorkspaceModel } from './model.ts' +export type { + WorkspaceFollowSink, WorkspaceListPhase, WorkspaceRemote, WorkspaceSnapshot, +} from './model.ts' +export { WorkspaceController, WorkspaceCreateError } from './service.ts' +export type { IWorkspaces, WorkspaceSource } from './service.ts' +export type { WorkspaceId, WorkspaceView } from '../types.ts' + +type WorkspaceStreamRemote = Pick & { + readonly workspace: WorkspaceRemote +} + +type WorkspaceBaselineFrame = Extract + +/** Gateway-owned snapshot stream configured for Workspace state. */ +export type WorkspaceStateStream = RemoteSnapshotStream< + WorkspaceBaselineFrame, + WorkspaceFollowIncrement +> + +declare module '@deepseek-ai/cordis' { + interface Context { + /** React-free Client Workspace state and commands. */ + workspaces: import('./service.ts').IWorkspaces + } +} + +/** Required Client Remote services. */ +export const inject = ['remote', 'remote.workspace'] + +/** + * Install Client Workspace state, commands, and reconnecting follow control. + * @param ctx - Client root Context. + */ +export function apply(ctx: Context): void { + const remote = ctx.remote as WorkspaceStreamRemote + const model = new ClientWorkspaceModel(remote.workspace) + new WorkspaceController(ctx, model) + const control = createWorkspaceStateStream(remote, { + accept: model, + carrierFailed: () => { model.handleCarrierFailure() }, + failed: (error) => { model.handleStreamFailure(error) }, + }) + control.start() + ctx.effect( + () => async () => { await control.dispose() }, + 'workspace-controller.client.control', + ) +} + +/** Domain sinks used by the Workspace state stream. */ +export interface WorkspaceStateStreamOptions { + /** Destinations for decoded Workspace state operations. */ + readonly accept: WorkspaceFollowSink + /** Observe a retryable carrier loss before reconnection. */ + readonly carrierFailed?: (error: RemoteStreamCarrierError) => void + /** Publish a terminal business or protocol failure. */ + readonly failed: (error: unknown) => void +} + +/** + * Create the reconnecting Workspace state stream. + * @param remote - generated Workspace namespace and Gateway stream factory. + * @param options - Workspace state destinations. + * @returns an unstarted stream owned by the Client Workspace runtime. + */ +export function createWorkspaceStateStream( + remote: WorkspaceStreamRemote, + options: WorkspaceStateStreamOptions, +): WorkspaceStateStream { + const stream = remote.$stream({ + name: 'Workspace state stream', + open: signal => remote.workspace.follow(signal), + ended: accepted => accepted + ? new RemoteStreamCarrierError('Workspace state stream ended without a terminal result') + : new Error('Workspace state stream ended before its opening snapshot'), + ...(options.carrierFailed === undefined ? {} : { carrierFailed: options.carrierFailed }), + }) + return new RemoteSnapshotStream(stream, { + name: 'Workspace state stream', + isSnapshot: (frame): frame is WorkspaceBaselineFrame => frame.type === 'baseline', + replace: (frame) => { options.accept.replaceBaseline(frame.value) }, + update: (frame) => { acceptIncrement(options.accept, frame) }, + failed: options.failed, + }) +} + +function acceptIncrement(accept: WorkspaceFollowSink, frame: WorkspaceFollowIncrement): void { + switch (frame.type) { + case 'upsert': + accept.upsertView(frame.workspace) + return + case 'remove': + accept.removeView(frame.workspaceId) + return + case 'order': + accept.replaceOrder(frame.workspaceIds) + return + case 'archived': + accept.replaceArchived(frame.archivedSessionIds) + return + /* v8 ignore next -- the generated Remote codec validates this closed union */ + default: + return assertNever(frame) + } +} + +/* v8 ignore next 3 -- closed-union backstop after generated Remote validation */ +function assertNever(value: never): never { + throw new Error(`unreachable Workspace increment: ${JSON.stringify(value)}`) +} diff --git a/packages/api/workspace-controller/src/client/model.ts b/packages/api/workspace-controller/src/client/model.ts new file mode 100644 index 0000000000..2ec365e8b2 --- /dev/null +++ b/packages/api/workspace-controller/src/client/model.ts @@ -0,0 +1,383 @@ +/** Client-side Workspace state model shared by Remote transport and UI projection. */ + +import { notifySubscribers } from '@deepseek-ai/dsh-client-store' +import type {} from '@deepseek-ai/dsh-api-workspace-controller/remote' +import type { RemoteFailure, RemoteResult, TypertClientRemote } from '@deepseek-ai/dsh-typert-protocol' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceBaseline, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteValue, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceValue, + WorkspaceId, + WorkspaceView, +} from '../types.ts' + +/** Complete generated `ctx.remote.workspace` namespace. */ +export type WorkspaceRemote = TypertClientRemote['workspace'] + +/** Monotone Workspace-list arrival lifecycle. */ +export type WorkspaceListPhase = 'pending' | 'ready' + +/** Immutable Client Workspace state. */ +export interface WorkspaceSnapshot { + readonly items: readonly WorkspaceView[] + /** Complete registry-global archive set in Host order. */ + readonly archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] + readonly state: 'idle' | 'loading' | 'error' + readonly phase: WorkspaceListPhase + readonly error: RemoteFailure | null +} + +/** State operations emitted by a decoded Workspace follow generation. */ +export interface WorkspaceFollowSink { + /** Replace all state from the generation baseline. */ + replaceBaseline(value: WorkspaceBaseline): void + /** Merge one Workspace row. */ + upsertView(workspace: WorkspaceView): void + /** Remove one Workspace row. */ + removeView(workspaceId: WorkspaceId): void + /** Replace the Host-confirmed Workspace order. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void + /** Replace the complete archived Session set. */ + replaceArchived(sessionIds: WorkspaceArchiveValue['archivedSessionIds']): void +} + +/** + * Owns the Client Workspace projection, mutation echoes, and stream/unary race resolution. + */ +export class ClientWorkspaceModel implements WorkspaceFollowSink { + private items: readonly WorkspaceView[] = [] + private archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds'] = [] + private state: WorkspaceSnapshot['state'] = 'loading' + private phase: WorkspaceListPhase = 'pending' + private error: RemoteFailure | null = null + /** Latest local reorder request; only its unary echo may install order. */ + private orderRequestGeneration = 0 + /** Increments on stream orders so a later remote commit outranks an older unary echo. */ + private orderFrameGeneration = 0 + /** Last complete order accepted from a baseline, increment, or current unary echo. */ + private committedOrder: WorkspaceId[] = [] + /** Host Workspace ids are never reused, so delayed data cannot resurrect a removed row. */ + private readonly removedIds = new Set() + private readonly listeners = new Set<() => void>() + private snapshotCache: WorkspaceSnapshot + private snapshotDirty = false + private notificationPending = false + private notificationScheduled = false + private notificationGeneration = 0 + + /** @param remote - generated Workspace Remote namespace. */ + constructor(private readonly remote: WorkspaceRemote) { + this.snapshotCache = this.buildSnapshot() + } + + /** + * Create or resolve a Workspace and merge the unary result immediately. + * @param input - existing absolute path to adopt. + * @returns generated Remote result. + */ + async create(input: WorkspaceCreateRequest): Promise> { + let result: RemoteResult + try { + result = await this.remote.create(input) + } catch (error) { + result = failureResult(error) + } + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Rename a Workspace and merge the unary result immediately. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns generated Remote result. + */ + async rename(workspaceId: WorkspaceId, title: string): Promise> { + const result = await this.remote.rename({ workspaceId, title }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Delete a Workspace and remove it from the local projection immediately. + * @param workspaceId - target Workspace. + * @returns generated Remote result. + */ + async delete(workspaceId: WorkspaceId): Promise> { + const result = await this.remote.delete({ workspaceId }) + if (result.ok) this.remove(workspaceId, true) + return result + } + + /** + * Optimistically move a Workspace and reconcile the returned complete order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + * @returns generated Remote result. + */ + async insertBefore( + workspaceId: WorkspaceId, + beforeWorkspaceId?: WorkspaceId, + ): Promise> { + const requestGeneration = ++this.orderRequestGeneration + const frameGeneration = this.orderFrameGeneration + const localOrder = this.items.map(workspace => workspace.workspaceId) + this.installOrder(insertIdBefore(localOrder, workspaceId, beforeWorkspaceId)) + let result: RemoteResult + try { + result = await this.remote.insertBefore({ + workspaceId, + ...beforeWorkspaceId === undefined ? {} : { beforeWorkspaceId }, + }) + } catch (error) { + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(this.committedOrder) + } + throw error + } + if (requestGeneration === this.orderRequestGeneration + && frameGeneration === this.orderFrameGeneration) { + this.installOrder(result.ok ? result.value.workspaceIds : this.committedOrder, result.ok) + } + return result + } + + /** + * Move a Session within its Workspace and merge the returned row. + * @param workspaceId - owning Workspace. + * @param sessionId - accounted Session to move. + * @param beforeSessionId - accounted anchor; omitted appends. + * @returns generated Remote result. + */ + async insertSessionBefore( + workspaceId: WorkspaceInsertSessionBeforeRequest['workspaceId'], + sessionId: WorkspaceInsertSessionBeforeRequest['sessionId'], + beforeSessionId?: WorkspaceInsertSessionBeforeRequest['beforeSessionId'], + ): Promise> { + const result = await this.remote.insertSessionBefore({ + workspaceId, + sessionId, + ...beforeSessionId === undefined ? {} : { beforeSessionId }, + }) + if (result.ok) this.upsert(result.value.workspace) + return result + } + + /** + * Archive one Session and install the returned complete archive set. + * @param sessionId - Session to archive. + * @returns generated Remote result. + */ + async archiveSession( + sessionId: WorkspaceArchiveSessionRequest['sessionId'], + ): Promise> { + const result = await this.remote.archiveSession({ sessionId }) + if (result.ok) this.installArchived(result.value.archivedSessionIds) + return result + } + + /** + * Replace the projection from one complete stream-generation baseline. + * @param baseline - complete Workspace and archive projection. + */ + replaceBaseline(baseline: WorkspaceBaseline): void { + this.orderFrameGeneration++ + this.installViews(baseline.items) + this.installArchived(baseline.archivedSessionIds) + this.state = 'idle' + this.phase = 'ready' + this.error = null + this.invalidate() + } + + /** Merge one decoded Workspace upsert from the current follow generation. */ + upsertView(workspace: WorkspaceView): void { + this.upsert(workspace) + } + + /** Apply one decoded Workspace removal from the current follow generation. */ + removeView(workspaceId: WorkspaceId): void { + this.remove(workspaceId) + } + + /** Replace Host-confirmed order from the current follow generation. */ + replaceOrder(workspaceIds: readonly WorkspaceId[]): void { + this.orderFrameGeneration++ + this.installOrder(workspaceIds, true) + } + + /** + * Replace the archived Session set from the current follow generation. + * @param archivedSessionIds - complete Host-confirmed archive set. + */ + replaceArchived(archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']): void { + this.installArchived(archivedSessionIds) + } + + /** Keep the last complete projection visible while a lost carrier reconnects. */ + handleCarrierFailure(): void { + this.state = 'loading' + this.error = null + this.invalidate() + } + + /** + * Publish a non-retryable stream or protocol failure. + * @param error - terminal stream failure. + */ + handleStreamFailure(error: unknown): void { + this.state = 'error' + this.error = failureOf(error) + this.invalidate() + } + + /** + * Subscribe to Workspace state invalidation. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void { + this.listeners.add(listener) + return () => { this.listeners.delete(listener) } + } + + /** + * Read the cached state, rebuilding it first when necessary. + * @returns the current stable Workspace list snapshot. + */ + getSnapshot(): WorkspaceSnapshot { + this.refreshSnapshot() + return this.snapshotCache + } + + private buildSnapshot(): WorkspaceSnapshot { + return { + items: this.items, + archivedSessionIds: this.archivedSessionIds, + state: this.state, + phase: this.phase, + error: this.error, + } + } + + private installArchived(archivedSessionIds: WorkspaceArchiveValue['archivedSessionIds']): void { + if (archivedSessionIds.length === this.archivedSessionIds.length + && archivedSessionIds.every((id, index) => id === this.archivedSessionIds[index])) return + this.archivedSessionIds = [...archivedSessionIds] + this.invalidate() + } + + private installOrder(workspaceIds: readonly WorkspaceId[], committed = false): void { + if (committed) this.committedOrder = [...workspaceIds] + const rank = new Map(workspaceIds.map((id, index) => [id, index])) + const items = [...this.items].sort((left, right) => + (rank.get(left.workspaceId) ?? Number.MAX_SAFE_INTEGER) + - (rank.get(right.workspaceId) ?? Number.MAX_SAFE_INTEGER)) + if (items.every((item, index) => item === this.items[index])) return + this.items = items + this.invalidate() + } + + private upsert(view: WorkspaceView): void { + if (this.removedIds.has(view.workspaceId)) return + const index = this.items.findIndex(item => item.workspaceId === view.workspaceId) + const installed = this.items[index] + // Unary responses and stream increments race on separate requests. Keep + // the newest Host projection regardless of their arrival order. + if (installed !== undefined && Date.parse(view.updatedAt) < Date.parse(installed.updatedAt)) return + if (!this.committedOrder.includes(view.workspaceId)) { + this.committedOrder = [view.workspaceId, ...this.committedOrder] + } + this.items = index === -1 + ? [view, ...this.items] + : this.items.map((item, position) => position === index ? view : item) + this.invalidate() + } + + private remove(workspaceId: WorkspaceId, immediate = false): void { + this.removedIds.add(workspaceId) + this.committedOrder = this.committedOrder.filter(id => id !== workspaceId) + const items = this.items.filter(item => item.workspaceId !== workspaceId) + if (items.length === this.items.length) { + // A successful unary echo still publishes an earlier increment's + // pending removal before the user operation resolves. + if (immediate) this.invalidate(true) + return + } + this.items = items + this.invalidate(immediate) + } + + private installViews(views: readonly WorkspaceView[]): void { + const installed = new Map() + for (const view of views) { + if (!this.removedIds.has(view.workspaceId)) installed.set(view.workspaceId, view) + } + this.items = [...installed.values()] + this.committedOrder = views.map(view => view.workspaceId) + } + + private invalidate(immediate = false): void { + this.snapshotDirty = true + this.notificationPending = true + if (immediate) { + this.notificationGeneration++ + this.notificationScheduled = false + this.flush() + return + } + if (this.notificationScheduled) return + this.notificationScheduled = true + const generation = ++this.notificationGeneration + queueMicrotask(() => { + if (generation !== this.notificationGeneration) return + this.notificationScheduled = false + this.flush() + }) + } + + private flush(): void { + if (!this.notificationPending || this.listeners.size === 0) return + this.notificationPending = false + this.refreshSnapshot() + notifySubscribers(this.listeners, '[workspace-controller]') + } + + private refreshSnapshot(): void { + if (!this.snapshotDirty) return + this.snapshotDirty = false + this.snapshotCache = this.buildSnapshot() + } +} + +function insertIdBefore( + ids: readonly WorkspaceId[], + id: WorkspaceId, + beforeId?: WorkspaceId, +): WorkspaceId[] { + if (!ids.includes(id) || (beforeId !== undefined && !ids.includes(beforeId)) || beforeId === id) { + return [...ids] + } + const without = ids.filter(candidate => candidate !== id) + const at = beforeId === undefined ? without.length : without.indexOf(beforeId) + return [...without.slice(0, at), id, ...without.slice(at)] +} + +function failureResult(error: unknown): RemoteResult { + return { ok: false, error: failureOf(error) } +} + +function failureOf(error: unknown): RemoteFailure { + return { + code: 'internal', + message: error instanceof Error ? error.message : String(error), + details: {}, + } +} diff --git a/packages/api/workspace-controller/src/client/service.ts b/packages/api/workspace-controller/src/client/service.ts new file mode 100644 index 0000000000..a8511ac61e --- /dev/null +++ b/packages/api/workspace-controller/src/client/service.ts @@ -0,0 +1,132 @@ +/** React-free Client Workspace service and command facade. */ + +import { Service, type Context } from '@deepseek-ai/cordis' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import type { WorkspaceView } from '../types.ts' +import type { ClientWorkspaceModel, WorkspaceSnapshot } from './model.ts' + +/** Structured create failure for callers that distinguish Host business errors. */ +export class WorkspaceCreateError extends Error { + override readonly name = 'WorkspaceCreateError' + + /** @param rpcError - Host business or folded transport failure. */ + constructor(readonly rpcError: RemoteFailure) { + super(`workspace create failed: ${rpcError.code}: ${rpcError.message}`) + } +} + +/** Bare observable source for the Workspace Controller snapshot. */ +export interface WorkspaceSource { + /** Read the identity-stable current snapshot. */ + getSnapshot(): WorkspaceSnapshot + /** + * Subscribe to snapshot changes. + * @param listener - invalidation callback. + * @returns unsubscribe function. + */ + subscribe(listener: () => void): () => void +} + +/** Workspace Controller's Client service face. */ +export interface IWorkspaces { + /** Host-authoritative Workspace rows, order, archive set, and follow lifecycle. */ + readonly list: WorkspaceSource + /** + * Register an existing path as a Workspace. + * @param input - Host create payload. + * @returns the created or idempotently resolved Workspace. + */ + create(input: { path: string }): Promise + /** + * Rename a Workspace. + * @param workspaceId - target Workspace. + * @param title - new display title. + * @returns the renamed Workspace. + */ + rename(workspaceId: WorkspaceId, title: string): Promise + /** + * Delete a Workspace registration without deleting Sessions or files. + * @param workspaceId - target Workspace. + */ + delete(workspaceId: WorkspaceId): Promise + /** + * Move a Workspace within the Host registry order. + * @param workspaceId - Workspace to move. + * @param beforeWorkspaceId - anchor Workspace; omitted appends. + */ + insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise + /** + * Archive a Session from Workspace grouping surfaces. + * @param sessionId - Session to archive. + */ + archiveSession(sessionId: SessionId): Promise + /** + * Move a Session within one Workspace account. + * @param workspaceId - owning Workspace. + * @param sessionId - Session to move. + * @param beforeSessionId - anchor Session; omitted appends. + * @returns the changed Workspace. + */ + insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise +} + +/** Owns the bare Workspace snapshot and Workspace-only commands. */ +export class WorkspaceController extends Service implements IWorkspaces { + readonly list: WorkspaceSource + + /** + * @param ctx - Client root Context. + * @param model - Remote-backed Workspace state model. + */ + constructor(ctx: Context, private readonly model: ClientWorkspaceModel) { + super(ctx, 'workspaces') + this.list = model + } + + async create(input: { path: string }): Promise { + const result = await this.model.create(input) + if (!result.ok) throw new WorkspaceCreateError(result.error) + return result.value.workspace + } + + async rename(workspaceId: WorkspaceId, title: string): Promise { + const result = await this.model.rename(workspaceId, title) + if (!result.ok) throw commandError('rename', result.error) + return result.value.workspace + } + + async delete(workspaceId: WorkspaceId): Promise { + const result = await this.model.delete(workspaceId) + if (!result.ok) throw commandError('delete', result.error) + } + + async insertBefore(workspaceId: WorkspaceId, beforeWorkspaceId?: WorkspaceId): Promise { + const result = await this.model.insertBefore(workspaceId, beforeWorkspaceId) + if (!result.ok) throw commandError('reorder', result.error) + } + + async archiveSession(sessionId: SessionId): Promise { + const result = await this.model.archiveSession(sessionId) + if (!result.ok) throw commandError('session archive', result.error) + } + + async insertSessionBefore( + workspaceId: WorkspaceId, + sessionId: SessionId, + beforeSessionId?: SessionId, + ): Promise { + const result = await this.model.insertSessionBefore(workspaceId, sessionId, beforeSessionId) + if (!result.ok) throw commandError('move', result.error) + return result.value.workspace + } +} + +function commandError(operation: string, failure: RemoteFailure): Error { + return new Error(`workspace ${operation} failed: ${failure.code}: ${failure.message}`) +} diff --git a/packages/api/workspace-controller/src/commands.ts b/packages/api/workspace-controller/src/commands.ts new file mode 100644 index 0000000000..0bb36b0897 --- /dev/null +++ b/packages/api/workspace-controller/src/commands.ts @@ -0,0 +1,196 @@ +/** Workspace command implementation and stable Remote failure mapping. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { Workspace } from '@deepseek-ai/dsh-workspace' +import { + WorkspaceId, + WorkspaceMoveInvalidError, + WorkspaceOrderInvalidError, + WorkspaceUnknownSessionError, +} from '@deepseek-ai/dsh-workspace' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import { workspaceView } from './feed.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, +} from './types.ts' + +/** Implements Workspace mutations against the authoritative registry. */ +export class WorkspaceCommands { + private operationTail = Promise.resolve() + + /** @param ctx - Host context containing the Workspace registry. */ + constructor(private readonly ctx: Context) {} + + /** + * Create or resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + create(request: WorkspaceCreateRequest): Promise { + return this.enqueue(async () => { + try { + const existing = await this.ctx.workspaceRegistry.resolveByPath(request.path) + if (existing !== undefined) { + return { workspace: workspaceView(existing), created: false } + } + const workspace = await this.ctx.workspaceRegistry.create(request.path) + return { workspace: workspaceView(workspace), created: true } + } catch (error) { + if (error instanceof TypertRemoteFailure) throw error + throw failure( + 'workspace-invalid-path', + `cannot create a Workspace at "${request.path}": ${errorMessage(error)}`, + { path: request.path }, + ) + } + }) + } + + /** + * Rename one Workspace after serializing title ownership checks. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + rename(request: WorkspaceRenameRequest): Promise { + const title = request.title.trim() + if (title === '') { + return Promise.reject(failure( + 'bad-request', + 'Workspace rename requires a non-blank title', + {}, + )) + } + return this.enqueue(async () => { + const workspace = this.requireWorkspace(request.workspaceId) + if (title !== workspace.title) { + if (this.ctx.workspaceRegistry.list().some(candidate => + candidate.id !== workspace.id && candidate.title === title)) { + throw failure( + 'workspace-name-conflict', + `Workspace name '${title}' is already in use`, + { name: title }, + ) + } + await workspace.setTitle(title) + } + return { workspace: workspaceView(workspace) } + }) + } + + /** + * Delete one Workspace registration without deleting its directory or Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + delete(request: WorkspaceDeleteRequest): Promise { + return this.enqueue(async () => { + if (!await this.ctx.workspaceRegistry.delete(WorkspaceId(request.workspaceId))) { + throw workspaceNotFound(request.workspaceId) + } + return { deleted: true } + }) + } + + /** + * Move one Workspace within the durable registry order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + async insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + try { + const workspaceIds = await this.ctx.workspaceRegistry.insertBefore( + WorkspaceId(request.workspaceId), + request.beforeWorkspaceId === undefined + ? undefined + : WorkspaceId(request.beforeWorkspaceId), + ) + return { workspaceIds: [...workspaceIds] } + } catch (error) { + if (!(error instanceof WorkspaceOrderInvalidError)) throw error + throw workspaceNotFound(error.workspaceId) + } + } + + /** + * Move one accounted Session within a Workspace's manual order. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + async insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + const workspace = this.requireWorkspace(request.workspaceId) + try { + await workspace.insertSessionBefore(request.sessionId, request.beforeSessionId) + } catch (error) { + if (!(error instanceof WorkspaceMoveInvalidError)) throw error + throw failure( + 'workspace-move-invalid', + error.message, + { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...request.beforeSessionId === undefined + ? {} + : { beforeSessionId: request.beforeSessionId }, + }, + ) + } + return { workspace: workspaceView(workspace) } + } + + /** + * Add one known Session to the registry-global archive set. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ + async archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + try { + await this.ctx.workspaceRegistry.archiveSession(request.sessionId) + } catch (error) { + if (!(error instanceof WorkspaceUnknownSessionError)) throw error + throw failure('session-not-found', error.message, { sessionId: request.sessionId }) + } + return { archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds] } + } + + private requireWorkspace(workspaceId: WorkspaceId): Workspace { + const workspace = this.ctx.workspaceRegistry.get(WorkspaceId(workspaceId)) + if (workspace === undefined) throw workspaceNotFound(workspaceId) + return workspace + } + + private enqueue(operation: () => Promise): Promise { + const result = this.operationTail.then(operation) + this.operationTail = result.then(() => undefined, () => undefined) + return result + } +} + +function workspaceNotFound(workspaceId: WorkspaceId): TypertRemoteFailure { + return failure( + 'workspace-not-found', + `Workspace "${workspaceId}" not found`, + { workspaceId }, + ) +} + +function failure( + code: string, + message: string, + details: object, +): TypertRemoteFailure { + return new TypertRemoteFailure({ code, message, details }) +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} diff --git a/packages/api/workspace-controller/src/feed.ts b/packages/api/workspace-controller/src/feed.ts new file mode 100644 index 0000000000..dcb1598c59 --- /dev/null +++ b/packages/api/workspace-controller/src/feed.ts @@ -0,0 +1,184 @@ +/** Reconnect-safe Workspace baseline and increment producer. */ + +import type { Context } from '@deepseek-ai/cordis' +import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' +import type { Workspace, WorkspaceRecord } from '@deepseek-ai/dsh-workspace' +import { + workspaceDomainState, + workspaceRecord, + WorkspaceId, +} from '@deepseek-ai/dsh-workspace' +import type { + WorkspaceBaseline, + WorkspaceFollowFrame, + WorkspaceView, +} from './types.ts' + +/** + * Project one authoritative Workspace entity into its Remote value. + * @param workspace - authoritative registry entity. + * @returns detached Workspace projection for Remote consumers. + */ +export function workspaceView(workspace: Workspace): WorkspaceView { + return { + workspaceId: workspace.id, + path: workspace.path, + title: workspace.title, + sessionIds: [...workspace.sessionIds], + createdAt: workspace.createdAt, + updatedAt: workspace.updatedAt, + } +} + +function changedWorkspaceView(workspaceId: string, value: unknown): WorkspaceView { + const record: WorkspaceRecord = workspaceRecord.parse(value) + return { + workspaceId: WorkspaceId(workspaceId), + path: record.path, + title: record.title, + sessionIds: [...record.sessionIds], + createdAt: record.createdAt, + updatedAt: record.updatedAt, + } +} + +/** Owns Workspace domain observation and all active follow generations. */ +export class WorkspaceFeed { + private readonly followers = new Set() + private knownIds: Set + private order: readonly string[] + private archived: readonly string[] + + /** @param ctx - Host context containing the authoritative Workspace registry. */ + constructor(private readonly ctx: Context) { + const baseline = ctx.workspaceRegistry.list() + this.knownIds = new Set(baseline.map(workspace => String(workspace.id))) + this.order = baseline.map(workspace => String(workspace.id)) + this.archived = ctx.workspaceRegistry.archivedSessionIds.map(String) + ctx.on('domain/changed', (change: DomainChanged) => { this.changed(change) }) + ctx.effect(() => () => { + for (const follower of this.followers) follower.close() + this.followers.clear() + }, 'workspace-controller.feed') + } + + /** + * Read the complete current projection synchronously. + * @returns all active Workspaces and archived Session identities. + */ + baseline(): WorkspaceBaseline { + return { + items: this.ctx.workspaceRegistry.list().map(workspaceView), + archivedSessionIds: [...this.ctx.workspaceRegistry.archivedSessionIds], + } + } + + /** + * Open one generation beginning with a complete baseline. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + async *follow(signal: AbortSignal): AsyncIterable { + signal.throwIfAborted() + const follower = new WorkspaceFollower() + this.followers.add(follower) + try { + yield { type: 'baseline', value: this.baseline() } + yield* follower.read(signal) + } finally { + this.followers.delete(follower) + follower.close() + } + } + + private changed(change: DomainChanged): void { + if (change.domain !== 'workspace') return + if (change.table === '') { + if (change.operation !== 'put') return + const state = workspaceDomainState.parse(change.value) + const nextOrder = state.workspaceIds.map(String) + const orderChanged = !sameStrings(this.order, nextOrder) + for (const id of state.workspaceIds) { + if (this.knownIds.has(id)) continue + const workspace = this.ctx.workspaceRegistry.get(id) + if (workspace === undefined) { + throw new Error(`committed Workspace registry references missing Workspace "${id}"`) + } + this.knownIds.add(id) + this.publish({ type: 'upsert', workspace: workspaceView(workspace) }) + } + this.order = nextOrder + if (orderChanged) this.publish({ type: 'order', workspaceIds: [...state.workspaceIds] }) + const nextArchived = state.archivedSessionIds.map(String) + if (!sameStrings(this.archived, nextArchived)) { + this.archived = nextArchived + this.publish({ type: 'archived', archivedSessionIds: [...state.archivedSessionIds] }) + } + return + } + if (change.table !== 'workspaces') return + if (change.operation === 'deleted') { + if (!this.knownIds.delete(change.key)) return + this.publish({ type: 'remove', workspaceId: WorkspaceId(change.key) }) + return + } + if (!this.knownIds.has(change.key)) return + this.publish({ + type: 'upsert', + workspace: changedWorkspaceView(change.key, change.value), + }) + } + + private publish(frame: Exclude): void { + for (const follower of this.followers) follower.push(frame) + } +} + +function sameStrings(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +class WorkspaceFollower { + private readonly frames: WorkspaceFollowFrame[] = [] + private waiting: (() => void) | undefined + private closed = false + + push(frame: WorkspaceFollowFrame): void { + /* v8 ignore next -- closed followers are removed before later publication can reach them. */ + if (this.closed) return + this.frames.push(frame) + this.waiting?.() + } + + close(): void { + if (this.closed) return + this.closed = true + this.waiting?.() + } + + async *read(signal: AbortSignal): AsyncIterable { + while (!this.closed && !signal.aborted) { + const frame = this.frames.shift() + if (frame !== undefined) { + yield frame + continue + } + await this.wait(signal) + } + } + + private wait(signal: AbortSignal): Promise { + return new Promise((resolve) => { + const finish = (): void => { + signal.removeEventListener('abort', finish) + /* v8 ignore next -- one read owns the sole installed wait callback. */ + if (this.waiting === finish) this.waiting = undefined + resolve() + } + this.waiting = finish + signal.addEventListener('abort', finish, { once: true }) + /* v8 ignore next -- native signals and the private queue cannot change during this synchronous setup. */ + if (signal.aborted || this.closed || this.frames.length > 0) finish() + }) + } +} diff --git a/packages/api/workspace-controller/src/index.ts b/packages/api/workspace-controller/src/index.ts new file mode 100644 index 0000000000..0fbd514cd5 --- /dev/null +++ b/packages/api/workspace-controller/src/index.ts @@ -0,0 +1,116 @@ +/** Host Workspace Remote owner: explicit commands and reconnect-safe state. */ + +import { Context } from '@deepseek-ai/cordis' +import { Remote, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' +import { WorkspaceCommands } from './commands.ts' +import { WorkspaceFeed } from './feed.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, +} from './types.ts' + +export type * from './types.ts' + +declare module '@deepseek-ai/cordis' { + interface Context { + /** Host Workspace business API and Remote namespace owner. */ + workspaceController: WorkspaceController + } +} + +/** Host service backing the generated `ctx.remote.workspace` namespace. */ +export class WorkspaceController extends TypertRemoteService { + static inject = ['typert', 'workspaceRegistry'] + + private readonly commands: WorkspaceCommands + private readonly feed: WorkspaceFeed + + /** @param ctx - Host context containing the Workspace registry. */ + constructor(ctx: Context) { + super(ctx, 'workspaceController', { namespace: 'workspace' }) + this.commands = new WorkspaceCommands(ctx) + this.feed = new WorkspaceFeed(ctx) + } + + /** + * Create or idempotently resolve one Workspace over an existing directory. + * @param request - directory path to register. + * @returns the Workspace and whether this call created it. + */ + @Remote('create') + create(request: WorkspaceCreateRequest): Promise { + return this.commands.create(request) + } + + /** + * Rename one Workspace to a unique non-blank title. + * @param request - Workspace identity and proposed title. + * @returns the updated Workspace projection. + */ + @Remote('rename') + rename(request: WorkspaceRenameRequest): Promise { + return this.commands.rename(request) + } + + /** + * Remove one Workspace registration while retaining files and Sessions. + * @param request - Workspace identity to remove. + * @returns deletion confirmation. + */ + @Remote('delete') + delete(request: WorkspaceDeleteRequest): Promise { + return this.commands.delete(request) + } + + /** + * Move one Workspace within the registry display order. + * @param request - moved Workspace and optional anchor. + * @returns the complete resulting Workspace order. + */ + @Remote('insertBefore') + insertBefore(request: WorkspaceInsertBeforeRequest): Promise { + return this.commands.insertBefore(request) + } + + /** + * Move one accounted Session within a Workspace. + * @param request - Workspace, Session, and optional anchor identities. + * @returns the updated Workspace projection. + */ + @Remote('insertSessionBefore') + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise { + return this.commands.insertSessionBefore(request) + } + + /** + * Hide one known Session from Workspace grouping surfaces. + * @param request - Session identity to archive. + * @returns the complete resulting archive set. + */ + @Remote('archiveSession') + archiveSession(request: WorkspaceArchiveSessionRequest): Promise { + return this.commands.archiveSession(request) + } + + /** + * Stream a complete Workspace baseline followed by ordered increments. + * @param signal - generation cancellation. + * @returns baseline followed by ordered Workspace increments. + */ + @Remote({ mode: 'stream' }) + follow(signal: AbortSignal): AsyncIterable { + return this.feed.follow(signal) + } +} + +export default WorkspaceController diff --git a/packages/api/workspace-controller/src/invariant.ts b/packages/api/workspace-controller/src/invariant.ts new file mode 100644 index 0000000000..1e2835db0e --- /dev/null +++ b/packages/api/workspace-controller/src/invariant.ts @@ -0,0 +1,20 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-api-workspace-controller/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-api-workspace-controller' + +/** Cordis companion plugin name. */ +export const name = 'api-workspace-controller-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** No runtime invariant: Workspace Registry owns persistence; every stream generation is a full projection. */ +const install: InvariantInstaller = () => {} + +/** Register this package's invariant companion. */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/api/workspace-controller/src/types.ts b/packages/api/workspace-controller/src/types.ts new file mode 100644 index 0000000000..f530e2ef12 --- /dev/null +++ b/packages/api/workspace-controller/src/types.ts @@ -0,0 +1,122 @@ +/** Browser-safe request, result, and state-stream vocabulary for Workspace Remote. */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +export type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' + +/** One durable Workspace projected for browser consumers. */ +export interface WorkspaceView { + readonly workspaceId: WorkspaceId + /** Canonical host directory path. */ + readonly path: string + /** User-visible title. */ + readonly title: string + /** Sessions accounted to this Workspace in manual order. */ + readonly sessionIds: readonly SessionId[] + /** ISO-8601 creation instant. */ + readonly createdAt: string + /** ISO-8601 last-mutation instant. */ + readonly updatedAt: string +} + +/** Stable Workspace failure details returned by unary methods. */ +export interface WorkspaceErrorDetailsMap { + 'bad-request': Record + 'workspace-invalid-path': { readonly path: string } + 'workspace-not-found': { readonly workspaceId: WorkspaceId } + 'workspace-name-conflict': { readonly name: string } + 'workspace-move-invalid': { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + 'session-not-found': { readonly sessionId: SessionId } +} + +/** Workspace business failure returned without throwing a carrier error. */ +export type WorkspaceError = { + [Code in keyof WorkspaceErrorDetailsMap]: { + readonly code: Code + readonly message: string + readonly details: WorkspaceErrorDetailsMap[Code] + } +}[keyof WorkspaceErrorDetailsMap] + +/** Existing directory requested for Workspace adoption. */ +export interface WorkspaceCreateRequest { + readonly path: string +} + +/** Created or previously registered Workspace. */ +export interface WorkspaceCreateValue { + readonly workspace: WorkspaceView + readonly created: boolean +} + +/** Workspace title mutation. */ +export interface WorkspaceRenameRequest { + readonly workspaceId: WorkspaceId + readonly title: string +} + +/** Workspace mutation returning the complete changed row. */ +export interface WorkspaceValue { + readonly workspace: WorkspaceView +} + +/** Workspace registration deletion. */ +export interface WorkspaceDeleteRequest { + readonly workspaceId: WorkspaceId +} + +/** Receipt after one Workspace registration is deleted. */ +export interface WorkspaceDeleteValue { + readonly deleted: true +} + +/** DOM-insertBefore-like Workspace order mutation. */ +export interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId + readonly beforeWorkspaceId?: WorkspaceId +} + +/** Complete Workspace registry order after a mutation. */ +export interface WorkspaceOrderValue { + readonly workspaceIds: readonly WorkspaceId[] +} + +/** DOM-insertBefore-like Session membership order mutation. */ +export interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId +} + +/** Session requested for archival from Workspace grouping surfaces. */ +export interface WorkspaceArchiveSessionRequest { + readonly sessionId: SessionId +} + +/** Complete archived Session set after a mutation. */ +export interface WorkspaceArchiveValue { + readonly archivedSessionIds: readonly SessionId[] +} + +/** Complete reconnect baseline for Workspace browser state. */ +export interface WorkspaceBaseline { + readonly items: readonly WorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] +} + +/** One ordered Workspace change after a generation's baseline. */ +export type WorkspaceFollowIncrement = + | { readonly type: 'upsert'; readonly workspace: WorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +/** Workspace state stream; every generation starts with exactly one baseline. */ +export type WorkspaceFollowFrame = + | { readonly type: 'baseline'; readonly value: WorkspaceBaseline } + | WorkspaceFollowIncrement diff --git a/packages/api/workspace-controller/tests/model.client.spec.ts b/packages/api/workspace-controller/tests/model.client.spec.ts new file mode 100644 index 0000000000..572f5b8f9c --- /dev/null +++ b/packages/api/workspace-controller/tests/model.client.spec.ts @@ -0,0 +1,384 @@ +import { describe, expect, it, vi } from 'vitest' +import { + ClientWorkspaceModel, type WorkspaceRemote, +} from '../src/client/index.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceValue, + WorkspaceError, + WorkspaceId, + WorkspaceView, +} from '../src/types.ts' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +const sid = (id: string): SessionId => id as SessionId +const wid = (id: string): WorkspaceId => id as WorkspaceId + +function workspace( + id: string, + sessionIds: readonly SessionId[] = [], + updatedAt = '2026-01-01T00:00:00.000Z', +): WorkspaceView { + return { + workspaceId: wid(id), + path: `/w/${id}`, + title: id, + sessionIds, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt, + } +} + +function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} + +function workspaceError(error: WorkspaceError): RemoteResult { + return { ok: false, error } +} + +interface Deferred { + readonly promise: Promise + resolve(value: T): void + reject(error: unknown): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + let reject!: (error: unknown) => void + const promise = new Promise((accept, fail) => { + resolve = accept + reject = fail + }) + return { promise, reject, resolve } +} + +class FakeWorkspaceRemote implements WorkspaceRemote { + readonly calls: Array<{ readonly method: string; readonly request: unknown }> = [] + onCreate: (request: WorkspaceCreateRequest) => Promise> = request => + Promise.resolve(remoteOk({ workspace: workspace(request.path.split('/').pop() ?? 'workspace'), created: true })) + onRename: (request: WorkspaceRenameRequest) => Promise> = request => + Promise.resolve(remoteOk({ workspace: { ...workspace(String(request.workspaceId)), title: request.title } })) + onDelete: (_request: WorkspaceDeleteRequest) => Promise> = () => + Promise.resolve(remoteOk({ deleted: true })) + onInsertBefore: ( + request: WorkspaceInsertBeforeRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ workspaceIds: [request.workspaceId] })) + onInsertSessionBefore: ( + request: WorkspaceInsertSessionBeforeRequest, + ) => Promise> = request => Promise.resolve(remoteOk({ + workspace: workspace(String(request.workspaceId), [request.sessionId]), + })) + onArchiveSession: ( + request: WorkspaceArchiveSessionRequest, + ) => Promise> = request => + Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + + create(request: WorkspaceCreateRequest): Promise> { + this.record('create', request) + return this.onCreate(request) + } + + rename(request: WorkspaceRenameRequest): Promise> { + this.record('rename', request) + return this.onRename(request) + } + + delete(request: WorkspaceDeleteRequest): Promise> { + this.record('delete', request) + return this.onDelete(request) + } + + insertBefore(request: WorkspaceInsertBeforeRequest): Promise> { + this.record('insertBefore', request) + return this.onInsertBefore(request) + } + + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise> { + this.record('insertSessionBefore', request) + return this.onInsertSessionBefore(request) + } + + archiveSession(request: WorkspaceArchiveSessionRequest): Promise> { + this.record('archiveSession', request) + return this.onArchiveSession(request) + } + + async *follow(_signal?: AbortSignal): AsyncGenerator {} + + private record(method: string, request: unknown): void { + this.calls.push({ method, request }) + } +} + +function modelFor(remote = new FakeWorkspaceRemote()): ClientWorkspaceModel { + return new ClientWorkspaceModel(remote) +} + +function baseline( + model: ClientWorkspaceModel, + items: readonly WorkspaceView[] = [], + archivedSessionIds: readonly SessionId[] = [], +): void { + model.replaceBaseline({ items, archivedSessionIds }) +} + +describe('ClientWorkspaceModel', () => { + it('replaces reconnect state and applies ordered increments', () => { + const model = modelFor() + expect(model.getSnapshot()).toMatchObject({ phase: 'pending', state: 'loading' }) + baseline(model, [workspace('old'), workspace('kept')]) + model.upsertView(workspace('new')) + model.replaceOrder([wid('kept'), wid('new'), wid('old')]) + model.replaceArchived([sid('hidden')]) + model.removeView(wid('old')) + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', archivedSessionIds: ['hidden'] }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept', 'new']) + + baseline(model, [workspace('fresh')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['fresh']) + expect(model.getSnapshot().archivedSessionIds).toEqual([]) + }) + + it('keeps the last baseline during retry and exposes a terminal stream failure', () => { + const model = modelFor() + baseline(model, [workspace('visible')]) + model.handleCarrierFailure() + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'loading', error: null }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['visible']) + model.handleStreamFailure(new Error('wire down')) + expect(model.getSnapshot()).toMatchObject({ + phase: 'ready', state: 'error', error: { code: 'internal', message: 'wire down' }, + }) + model.handleStreamFailure('plain failure') + expect(model.getSnapshot().error?.message).toBe('plain failure') + baseline(model, [workspace('restored')]) + expect(model.getSnapshot()).toMatchObject({ phase: 'ready', state: 'idle', error: null }) + }) + + it('creates by path, prepends the returned row, and folds rejected calls', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + remote.onCreate = request => Promise.resolve(remoteOk({ + workspace: workspace('created', [], '2026-02-01T00:00:00.000Z'), + created: request.path === '/w/created', + })) + await expect(model.create({ path: '/w/created' })).resolves.toMatchObject({ ok: true }) + expect(remote.calls).toContainEqual({ method: 'create', request: { path: '/w/created' } }) + expect(model.getSnapshot().items[0]?.workspaceId).toBe('created') + + remote.onCreate = () => Promise.reject(new Error('create transport')) + await expect(model.create({ path: '/w/existing' })).resolves.toMatchObject({ + ok: false, error: { code: 'internal', message: 'create transport' }, + }) + }) + + it('lets newer stream order outrank unary echoes and rolls failures back', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one'), workspace('two'), workspace('three')]) + + const gate = deferred>() + remote.onInsertBefore = () => gate.promise + const pending = model.insertBefore(wid('three'), wid('one')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + model.replaceOrder([wid('one'), wid('three'), wid('two')]) + gate.resolve(remoteOk({ workspaceIds: [wid('three'), wid('one'), wid('two')] })) + await pending + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + remote.onInsertBefore = () => Promise.resolve(workspaceError({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('three') }, + })) + const rejected = model.insertBefore(wid('three')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + await expect(rejected).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + + remote.onInsertBefore = () => Promise.reject(new Error('transport down')) + const disconnected = model.insertBefore(wid('three'), wid('one')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['three', 'one', 'two']) + await expect(disconnected).rejects.toThrow('transport down') + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'three', 'two']) + }) + + it('keeps a newer optimistic reorder when an older transport call rejects', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one'), workspace('two'), workspace('three')]) + const firstGate = deferred>() + const secondGate = deferred>() + let request = 0 + remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise + + const first = model.insertBefore(wid('three'), wid('one')) + const second = model.insertBefore(wid('two'), wid('three')) + firstGate.reject(new Error('first transport failed')) + await expect(first).rejects.toThrow('first transport failed') + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + secondGate.resolve(remoteOk({ workspaceIds: [wid('two'), wid('three'), wid('one')] })) + await expect(second).resolves.toMatchObject({ ok: true }) + }) + + it('rolls overlapping rejected reorders back to the last Host order', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one'), workspace('two'), workspace('three')]) + const firstGate = deferred>() + const secondGate = deferred>() + let request = 0 + remote.onInsertBefore = () => request++ === 0 ? firstGate.promise : secondGate.promise + + const first = model.insertBefore(wid('three'), wid('one')) + const second = model.insertBefore(wid('two'), wid('three')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + firstGate.resolve(workspaceError({ + code: 'workspace-not-found', message: 'first rejected', details: { workspaceId: wid('three') }, + })) + await expect(first).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'three', 'one']) + secondGate.resolve(workspaceError({ + code: 'workspace-not-found', message: 'second rejected', details: { workspaceId: wid('two') }, + })) + await expect(second).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two', 'three']) + }) + + it('retains removal tombstones across later baselines', () => { + const model = modelFor() + baseline(model, [workspace('gone'), workspace('kept')]) + model.removeView(wid('gone')) + model.removeView(wid('gone')) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['kept']) + baseline(model, [workspace('gone')]) + expect(model.getSnapshot().items).toEqual([]) + }) + + it('does not let delayed unary data resurrect a removed Workspace', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('gone')]) + const gate = deferred>() + remote.onRename = () => gate.promise + const rename = model.rename(wid('gone'), 'late') + model.removeView(wid('gone')) + gate.resolve(remoteOk({ workspace: { ...workspace('gone'), title: 'late' } })) + await expect(rename).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().items).toEqual([]) + }) + + it('applies Workspace mutation echoes and leaves failed results unchanged', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('one', [sid('first'), sid('second')])], [sid('archived')]) + + remote.onRename = () => Promise.resolve(workspaceError({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, + })) + await expect(model.rename(wid('one'), 'ignored')).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items[0]?.title).toBe('one') + + remote.onDelete = () => Promise.resolve(workspaceError({ + code: 'workspace-not-found', message: 'gone', details: { workspaceId: wid('one') }, + })) + await expect(model.delete(wid('one'))).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().items).toHaveLength(1) + + remote.onInsertSessionBefore = request => Promise.resolve(remoteOk({ + workspace: workspace('one', [request.sessionId, sid('first')], '2026-02-01T00:00:00.000Z'), + })) + await expect(model.insertSessionBefore(wid('one'), sid('second'), sid('first'))) + .resolves.toMatchObject({ ok: true }) + expect(remote.calls).toContainEqual({ + method: 'insertSessionBefore', + request: { workspaceId: 'one', sessionId: 'second', beforeSessionId: 'first' }, + }) + + remote.onInsertSessionBefore = () => Promise.resolve(workspaceError({ + code: 'workspace-move-invalid', + message: 'invalid move', + details: { workspaceId: wid('one'), sessionId: sid('second') }, + })) + await expect(model.insertSessionBefore(wid('one'), sid('second'))) + .resolves.toMatchObject({ ok: false }) + expect(remote.calls).toContainEqual({ + method: 'insertSessionBefore', + request: { workspaceId: 'one', sessionId: 'second' }, + }) + + remote.onArchiveSession = () => Promise.resolve(workspaceError({ + code: 'session-not-found', message: 'missing', details: { sessionId: sid('missing') }, + })) + await expect(model.archiveSession(sid('missing'))).resolves.toMatchObject({ ok: false }) + expect(model.getSnapshot().archivedSessionIds).toEqual(['archived']) + remote.onArchiveSession = request => Promise.resolve(remoteOk({ archivedSessionIds: [request.sessionId] })) + await expect(model.archiveSession(sid('fresh'))).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().archivedSessionIds).toEqual(['fresh']) + }) + + it('keeps the newest row and places Workspaces missing from partial orders last', async () => { + const model = modelFor() + baseline(model, [ + workspace('one', [], '2026-02-01T00:00:00.000Z'), + workspace('two'), + ]) + model.upsertView(workspace('one', [], '2025-12-01T00:00:00.000Z')) + expect(model.getSnapshot().items[0]?.updatedAt).toBe('2026-02-01T00:00:00.000Z') + model.upsertView(workspace('one', [sid('new')], '2026-03-01T00:00:00.000Z')) + expect(model.getSnapshot().items[0]?.sessionIds).toEqual(['new']) + + model.replaceOrder([wid('one')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two']) + model.replaceOrder([wid('two')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['two', 'one']) + model.replaceOrder([wid('one')]) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two']) + + await expect(model.insertBefore(wid('one'), wid('one'))).resolves.toMatchObject({ ok: true }) + expect(model.getSnapshot().items.map(item => item.workspaceId)).toEqual(['one', 'two']) + }) + + it('notifies subscribers and cancels a queued notification after an immediate delete echo', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('gone')]) + await Promise.resolve() + const listener = vi.fn() + const unsubscribe = model.subscribe(listener) + + const deletion = model.delete(wid('gone')) + model.removeView(wid('gone')) + await expect(deletion).resolves.toMatchObject({ ok: true }) + expect(listener).toHaveBeenCalledOnce() + await Promise.resolve() + expect(listener).toHaveBeenCalledOnce() + + unsubscribe() + model.handleCarrierFailure() + await Promise.resolve() + expect(listener).toHaveBeenCalledOnce() + }) + + it('removes from a unary delete echo before the operation resolves', async () => { + const remote = new FakeWorkspaceRemote() + const model = modelFor(remote) + baseline(model, [workspace('gone')]) + await expect(model.delete(wid('gone'))).resolves.toMatchObject({ ok: true }) + expect(remote.calls).toContainEqual({ method: 'delete', request: { workspaceId: 'gone' } }) + expect(model.getSnapshot().items).toEqual([]) + model.removeView(wid('gone')) + expect(model.getSnapshot().items).toEqual([]) + }) +}) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts new file mode 100644 index 0000000000..d71cfba44e --- /dev/null +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -0,0 +1,496 @@ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it, vi } from 'vitest' +import { + RemoteStream, + RemoteStreamCarrierError, + type RemoteStreamOptions, +} from '@deepseek-ai/dsh-api-gateway/client' +import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' +import { SessionId } from '@deepseek-ai/dsh-session/types' +import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol' +import * as WorkspaceClientPlugin from '../src/client/index.ts' +import { + ClientWorkspaceModel, + createWorkspaceStateStream, + WorkspaceController, + WorkspaceCreateError, + type WorkspaceFollowSink, + type WorkspaceRemote, +} from '../src/client/index.ts' +import type { + WorkspaceArchiveSessionRequest, + WorkspaceArchiveValue, + WorkspaceCreateRequest, + WorkspaceCreateValue, + WorkspaceDeleteRequest, + WorkspaceDeleteValue, + WorkspaceFollowFrame, + WorkspaceInsertBeforeRequest, + WorkspaceInsertSessionBeforeRequest, + WorkspaceOrderValue, + WorkspaceRenameRequest, + WorkspaceError, + WorkspaceId, + WorkspaceValue, + WorkspaceView, +} from '../src/types.ts' + +const AVAILABLE_CONNECTION = { + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', cwd: '/fixture', attachedSessions: 0, home: '/home/fixture', canOpenPath: true, + }), + subscribe: () => () => {}, + }, +} + +function workspaceClient( + remote: WorkspaceRemote, + connection: Pick = AVAILABLE_CONNECTION, +) { + return { + workspace: remote, + $stream: (options: RemoteStreamOptions) => new RemoteStream(connection, options), + } +} + +interface Generation { + readonly frames: readonly WorkspaceFollowFrame[] + readonly error?: unknown + readonly hold?: boolean + readonly afterAbort?: () => void + readonly afterAbortError?: unknown +} + +const baseline = (id?: string): Extract => ({ + type: 'baseline', + value: { + items: id === undefined ? [] : [{ + workspaceId: id as never, + path: `/work/${id}`, + title: id, + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }], + archivedSessionIds: [], + }, +}) + +const wid = (id: string): WorkspaceId => id as WorkspaceId +const sid = (id: string): SessionId => SessionId(id) + +function workspace(id: string, overrides: Partial = {}): WorkspaceView { + return { + workspaceId: wid(id), + path: `/work/${id}`, + title: id, + sessionIds: [], + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + } +} + +function remoteOk(value: T): RemoteResult { + return { ok: true, value } +} + +function remoteFailure(error: WorkspaceError): RemoteResult { + return { ok: false, error } +} + +function accepts(overrides: Partial = {}): WorkspaceFollowSink { + const ignore = (): void => {} + return { + replaceBaseline: ignore, + upsertView: ignore, + removeView: ignore, + replaceOrder: ignore, + replaceArchived: ignore, + ...overrides, + } +} + +class ScriptedWorkspaceRemote implements WorkspaceRemote { + readonly signals: AbortSignal[] = [] + calls = 0 + + constructor(private readonly generations: readonly Generation[]) {} + + create(_request: WorkspaceCreateRequest): Promise> { + throw new Error('unused') + } + + rename(_request: WorkspaceRenameRequest): Promise> { + throw new Error('unused') + } + + delete(_request: WorkspaceDeleteRequest): Promise> { + throw new Error('unused') + } + + insertBefore(_request: WorkspaceInsertBeforeRequest): Promise> { + throw new Error('unused') + } + + insertSessionBefore(_request: WorkspaceInsertSessionBeforeRequest): Promise> { + throw new Error('unused') + } + + archiveSession(_request: WorkspaceArchiveSessionRequest): Promise> { + throw new Error('unused') + } + + async *follow(signal = new AbortController().signal): AsyncIterable { + const generation = this.generations[this.calls++] + if (generation === undefined) throw new Error('no scripted Workspace generation') + this.signals.push(signal) + for (const frame of generation.frames) yield frame + if (generation.error !== undefined) throw generation.error + if (generation.hold === true && !signal.aborted) { + await new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + generation.afterAbort?.() + if (generation.afterAbortError !== undefined) throw generation.afterAbortError + } + } +} + +class CommandWorkspaceRemote implements WorkspaceRemote { + readonly create = vi.fn(request => Promise.resolve(remoteOk({ + workspace: workspace('created', { path: request.path }), + created: true, + }))) + + readonly rename = vi.fn(request => Promise.resolve(remoteOk({ + workspace: workspace(String(request.workspaceId), { title: request.title }), + }))) + + readonly delete = vi.fn(() => Promise.resolve(remoteOk({ deleted: true }))) + + readonly insertBefore = vi.fn(request => Promise.resolve(remoteOk({ + workspaceIds: [request.workspaceId], + }))) + + readonly insertSessionBefore = vi.fn(request => Promise.resolve(remoteOk({ + workspace: workspace(String(request.workspaceId), { sessionIds: [request.sessionId] }), + }))) + + readonly archiveSession = vi.fn(request => Promise.resolve(remoteOk({ + archivedSessionIds: [request.sessionId], + }))) + + async *follow(_signal?: AbortSignal): AsyncIterable {} +} + +async function waitFor(check: () => void): Promise { + for (let attempt = 0; attempt < 40; attempt++) { + try { + check() + return + } catch { + await Promise.resolve() + } + } + check() +} + +function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { + const connection: ConnectionHandle = { + api: {} as ConnectionHandle['api'], + isLoopback: true, + hostDescription: { + getSnapshot: () => ({ + version: 'fixture', + cwd: '/fixture', + attachedSessions: 0, + home: '/home/fixture', + canOpenPath: true, + }), + subscribe: () => () => {}, + }, + rpc: { + call: () => Promise.reject(new Error('unexpected generic RPC call')), + }, + registerGenerationSource: () => () => {}, + start: () => ({ stop: () => {} }), + } + ctx.reflect.provide('connection', connection) + ctx.reflect.provide('remote', workspaceClient(remote, connection)) + ctx.reflect.provide('remote.workspace', remote) +} + +describe('Workspace Controller Client apply', () => { + it('provides the Workspace service and stops its follow generation with the plugin fiber', async () => { + const ctx = new Context() + const remote = new ScriptedWorkspaceRemote([{ frames: [baseline('mounted')], hold: true }]) + provideClientServices(ctx, remote) + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + phase: 'ready', + state: 'idle', + items: [{ workspaceId: 'mounted' }], + }) + }) + + await fiber.dispose() + + expect(remote.signals[0]?.aborted).toBe(true) + expect(ctx.get('workspaces')).toBeUndefined() + }) + + it('marks carrier loss while retrying and publishes a later protocol failure', async () => { + const ctx = new Context() + const remote = new ScriptedWorkspaceRemote([ + { + frames: [baseline('old')], + error: new RemoteStreamCarrierError('generation lost'), + }, + { frames: [baseline('fresh'), baseline('duplicate')] }, + ]) + provideClientServices(ctx, remote) + const carrierFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleCarrierFailure') + const streamFailure = vi.spyOn(ClientWorkspaceModel.prototype, 'handleStreamFailure') + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + phase: 'ready', + state: 'error', + items: [{ workspaceId: 'fresh' }], + error: { code: 'internal', message: 'Workspace state stream emitted more than one opening snapshot' }, + }) + }) + + expect(carrierFailure).toHaveBeenCalledOnce() + expect(streamFailure).toHaveBeenCalledOnce() + await fiber.dispose() + }) +}) + +describe('Workspace state stream', () => { + it('delivers one baseline followed by increments', async () => { + const opening = baseline('one') + const workspace = opening.value.items[0]! + const remote = new ScriptedWorkspaceRemote([{ + frames: [ + opening, + { type: 'upsert', workspace }, + { type: 'remove', workspaceId: workspace.workspaceId }, + { type: 'order', workspaceIds: [workspace.workspaceId] }, + { type: 'archived', archivedSessionIds: ['session-one' as never] }, + ], + hold: true, + }]) + const replaceBaseline = vi.fn() + const upsertView = vi.fn() + const removeView = vi.fn() + const replaceOrder = vi.fn() + const replaceArchived = vi.fn() + const accept = accepts({ + replaceBaseline, + upsertView, + removeView, + replaceOrder, + replaceArchived, + }) + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept, + failed: vi.fn(), + }) + + stream.start() + stream.start() + await vi.waitFor(() => { expect(replaceArchived).toHaveBeenCalledOnce() }) + + expect(replaceBaseline).toHaveBeenCalledWith(opening.value) + expect(upsertView).toHaveBeenCalledWith(workspace) + expect(removeView).toHaveBeenCalledWith(workspace.workspaceId) + expect(replaceOrder).toHaveBeenCalledWith([workspace.workspaceId]) + expect(replaceArchived).toHaveBeenCalledWith(['session-one']) + await stream.dispose() + expect(remote.signals[0]?.aborted).toBe(true) + }) + + it('retains the old state across carrier loss and applies the replacement baseline', async () => { + const carrier = new RemoteStreamCarrierError('socket lost') + const remote = new ScriptedWorkspaceRemote([ + { frames: [baseline('old')], error: carrier }, + { frames: [baseline('fresh')], hold: true }, + ]) + const replaceBaseline = vi.fn() + const carrierFailed = vi.fn() + const failed = vi.fn() + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept: accepts({ replaceBaseline }), + carrierFailed, + failed, + }) + + stream.start() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) + + expect(replaceBaseline.mock.calls.map(([value]) => value.items[0]?.title)).toEqual(['old', 'fresh']) + expect(carrierFailed).toHaveBeenCalledWith(carrier) + expect(failed).not.toHaveBeenCalled() + await stream.dispose() + }) + + it('classifies a normal end after the opening baseline as carrier loss', async () => { + const remote = new ScriptedWorkspaceRemote([ + { frames: [baseline('old')] }, + { frames: [baseline('fresh')], hold: true }, + ]) + const replaceBaseline = vi.fn() + const carrierFailed = vi.fn() + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept: accepts({ replaceBaseline }), + carrierFailed, + failed: vi.fn(), + }) + + stream.start() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) + expect(carrierFailed.mock.calls[0]?.[0]).toMatchObject({ + message: 'Workspace state stream ended without a terminal result', + }) + await stream.dispose() + }) + + it('suppresses callback failure after disposal begins', async () => { + const failed = vi.fn() + let closing: Promise | undefined + const stream = createWorkspaceStateStream( + workspaceClient(new ScriptedWorkspaceRemote([{ frames: [baseline()] }])), + { + accept: accepts({ + replaceBaseline: () => { + closing = stream.dispose() + throw new Error('disposed callback') + }, + }), + failed, + }, + ) + + stream.start() + await vi.waitFor(() => { expect(closing).toBeDefined() }) + await closing + expect(failed).not.toHaveBeenCalled() + }) + + it.each([ + { + name: 'an increment before the baseline', + frames: [{ type: 'remove', workspaceId: 'one' as never }] as WorkspaceFollowFrame[], + message: 'update before its opening snapshot', + }, + { + name: 'a duplicate baseline', + frames: [baseline(), baseline()] as WorkspaceFollowFrame[], + message: 'more than one opening snapshot', + }, + { + name: 'a normal end before the baseline', + frames: [] as WorkspaceFollowFrame[], + message: 'ended before its opening snapshot', + }, + ])('reports $name as a terminal failure', async ({ frames, message }) => { + const failed = vi.fn() + const stream = createWorkspaceStateStream( + workspaceClient(new ScriptedWorkspaceRemote([{ frames }])), + { accept: accepts(), failed }, + ) + + stream.start() + await vi.waitFor(() => { expect(failed).toHaveBeenCalledOnce() }) + const failure: unknown = failed.mock.calls[0]?.[0] + expect(failure).toBeInstanceOf(Error) + if (!(failure instanceof Error)) throw new Error('expected Workspace stream failure') + expect(failure.message).toContain(message) + await stream.dispose() + }) + + it('restarts a live generation without reporting cancellation as failure', async () => { + const remote = new ScriptedWorkspaceRemote([ + { frames: [baseline('first')], hold: true }, + { frames: [baseline('second')], hold: true }, + ]) + const replaceBaseline = vi.fn() + const failed = vi.fn() + const stream = createWorkspaceStateStream(workspaceClient(remote), { + accept: accepts({ replaceBaseline }), + failed, + }) + + stream.start() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledOnce() }) + stream.restart() + await vi.waitFor(() => { expect(replaceBaseline).toHaveBeenCalledTimes(2) }) + expect(failed).not.toHaveBeenCalled() + await stream.dispose() + }) +}) + +describe('WorkspaceController', () => { + it('publishes the model source and exposes successful Workspace commands', async () => { + const remote = new CommandWorkspaceRemote() + const model = new ClientWorkspaceModel(remote) + model.replaceBaseline({ items: [workspace('one')], archivedSessionIds: [] }) + const controller = new WorkspaceController(new Context(), model) + + expect(controller.list).toBe(model) + await expect(controller.create({ path: '/work/created' })).resolves.toMatchObject({ workspaceId: 'created' }) + await expect(controller.rename(wid('one'), 'renamed')).resolves.toMatchObject({ title: 'renamed' }) + await expect(controller.insertBefore(wid('one'))).resolves.toBeUndefined() + await expect(controller.insertSessionBefore(wid('one'), sid('session'))).resolves.toMatchObject({ + sessionIds: ['session'], + }) + await expect(controller.archiveSession(sid('session'))).resolves.toBeUndefined() + await expect(controller.delete(wid('one'))).resolves.toBeUndefined() + }) + + it('maps generated business failures to the command facade errors', async () => { + const remote = new CommandWorkspaceRemote() + const controller = new WorkspaceController(new Context(), new ClientWorkspaceModel(remote)) + const missingWorkspace: WorkspaceError = { + code: 'workspace-not-found', + message: 'gone', + details: { workspaceId: wid('missing') }, + } + const missingSession: WorkspaceError = { + code: 'session-not-found', + message: 'missing session', + details: { sessionId: sid('session') }, + } + + remote.create.mockResolvedValueOnce(remoteFailure({ + code: 'workspace-invalid-path', + message: 'missing path', + details: { path: '/missing' }, + })) + const create = controller.create({ path: '/missing' }) + await expect(create).rejects.toBeInstanceOf(WorkspaceCreateError) + await expect(create).rejects.toThrow('workspace-invalid-path: missing path') + + remote.rename.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + await expect(controller.rename(wid('missing'), 'name')).rejects.toThrow('workspace rename failed: workspace-not-found: gone') + remote.delete.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + await expect(controller.delete(wid('missing'))).rejects.toThrow('workspace delete failed: workspace-not-found: gone') + remote.insertBefore.mockResolvedValueOnce(remoteFailure(missingWorkspace)) + await expect(controller.insertBefore(wid('missing'))).rejects.toThrow('workspace reorder failed: workspace-not-found: gone') + remote.archiveSession.mockResolvedValueOnce(remoteFailure(missingSession)) + await expect(controller.archiveSession(sid('session'))).rejects.toThrow('workspace session archive failed: session-not-found: missing session') + remote.insertSessionBefore.mockResolvedValueOnce(remoteFailure({ + code: 'workspace-move-invalid', + message: 'invalid move', + details: { workspaceId: wid('missing'), sessionId: sid('session') }, + })) + await expect(controller.insertSessionBefore(wid('missing'), sid('session'))) + .rejects.toThrow('workspace move failed: workspace-move-invalid: invalid move') + }) +}) diff --git a/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts new file mode 100644 index 0000000000..88e2e65964 --- /dev/null +++ b/packages/api/workspace-controller/tests/workspace-controller.host.spec.ts @@ -0,0 +1,333 @@ +import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import Storage from '@deepseek-ai/dsh-storage' +import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' +import { TypertRemoteFailure } from '@deepseek-ai/dsh-typert-protocol' +import WorkspaceRegistry from '@deepseek-ai/dsh-workspace' +import type { WorkspaceId } from '@deepseek-ai/dsh-workspace/types' +import WorkspaceController from '../src/index.ts' +import { WorkspaceFeed } from '../src/feed.ts' +import type { WorkspaceFollowFrame } from '../src/types.ts' +import { MemoryStorageBackend } from '../../../storage/storage-domain/tests/helpers/memory-backend.ts' + +const roots: Context[] = [] + +afterEach(async () => { + await Promise.all(roots.splice(0).map(ctx => ctx.fiber.dispose())) +}) + +interface Deferred { + readonly promise: Promise + resolve(value: T): void +} + +function deferred(): Deferred { + let resolve!: (value: T) => void + const promise = new Promise((settle) => { resolve = settle }) + return { promise, resolve } +} + +async function harness() { + const root = realpathSync.native(mkdtempSync(join(tmpdir(), 'dsh-workspace-controller-'))) + const ctx = new Context() + roots.push(ctx) + await ctx.plugin(SessionStore) + await ctx.plugin(Storage) + ctx.storage.backend.register('memory', new MemoryStorageBackend()) + const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) + ctx.storage.mount('domain', storageDomain) + ctx.provide('storageDomain', storageDomain) + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + await ctx.plugin(WorkspaceRegistry) + const dispose = (): void => {} + ctx.provide('typert', { + lookups: { configure: () => dispose }, + contexts: { configureHost: () => dispose }, + } as never) + const controller = new WorkspaceController(ctx) + return { controller, ctx, root, storageDomain } +} + +function stageDir(root: string, name: string): string { + const path = join(root, name) + mkdirSync(path, { recursive: true }) + return path +} + +async function nextFrame( + iterator: AsyncIterator, +): Promise { + const next = await iterator.next() + if (next.done === true) throw new Error('Workspace stream ended before the expected frame') + return next.value +} + +describe('WorkspaceController commands', () => { + it('serializes concurrent path adoption and preserves an existing title', async () => { + const { controller, root } = await harness() + const path = stageDir(root, 'alpha') + const results = await Promise.all([ + controller.create({ path }), + controller.create({ path }), + ]) + const created = results.find(result => result.created) + const resolved = results.find(result => !result.created) + expect(created).toMatchObject({ workspace: { path, title: 'alpha' } }) + expect(resolved?.workspace.workspaceId).toBe(created?.workspace.workspaceId) + + const workspaceId = created?.workspace.workspaceId + if (workspaceId === undefined) throw new Error('fixture did not create a Workspace') + await controller.rename({ workspaceId, title: 'renamed' }) + await expect(controller.create({ path })).resolves.toMatchObject({ + created: false, + workspace: { workspaceId, title: 'renamed' }, + }) + }) + + it('maps invalid paths, blank names, conflicts, and unknown ids to stable failures', async () => { + const { controller, root } = await harness() + const first = await controller.create({ path: stageDir(root, 'first') }) + const second = await controller.create({ path: stageDir(root, 'second') }) + + await expect(controller.create({ path: join(root, 'missing') })).rejects.toMatchObject({ + failure: { code: 'workspace-invalid-path', details: { path: join(root, 'missing') } }, + }) + expect(existsSync(join(root, 'missing'))).toBe(false) + await expect(controller.rename({ workspaceId: first.workspace.workspaceId, title: ' ' })) + .rejects.toMatchObject({ failure: { code: 'bad-request' } }) + await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'occupied' }) + await expect(controller.rename({ workspaceId: second.workspace.workspaceId, title: ' occupied ' })) + .rejects.toMatchObject({ failure: { code: 'workspace-name-conflict' } }) + await expect(controller.delete({ workspaceId: 'missing' as WorkspaceId })) + .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + }) + + it('preserves Remote failures and propagates unexpected registry failures', async () => { + const { controller, ctx, root } = await harness() + const remoteFailure = new TypertRemoteFailure({ + code: 'fixture-failure', + message: 'already mapped', + details: {}, + }) + const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath') + .mockRejectedValueOnce(remoteFailure) + .mockRejectedValueOnce('plain failure') + await expect(controller.create({ path: stageDir(root, 'remote-failure') })) + .rejects.toBe(remoteFailure) + const plainFailure = controller.create({ path: stageDir(root, 'plain-failure') }) + await expect(plainFailure).rejects.toMatchObject({ + failure: { code: 'workspace-invalid-path' }, + }) + await expect(plainFailure).rejects.toThrow('plain failure') + resolveByPath.mockRestore() + + const created = await controller.create({ path: stageDir(root, 'created') }) + const workspace = ctx.workspaceRegistry.get(created.workspace.workspaceId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + + const orderFailure = new Error('order storage failed') + vi.spyOn(ctx.workspaceRegistry, 'insertBefore').mockRejectedValueOnce(orderFailure) + await expect(controller.insertBefore({ workspaceId: created.workspace.workspaceId })) + .rejects.toBe(orderFailure) + + const moveFailure = new Error('membership storage failed') + vi.spyOn(workspace, 'insertSessionBefore').mockRejectedValueOnce(moveFailure) + await expect(controller.insertSessionBefore({ + workspaceId: created.workspace.workspaceId, + sessionId: SessionId('session'), + })).rejects.toBe(moveFailure) + + const archiveFailure = new Error('archive storage failed') + vi.spyOn(ctx.workspaceRegistry, 'archiveSession').mockRejectedValueOnce(archiveFailure) + await expect(controller.archiveSession({ sessionId: SessionId('session') })) + .rejects.toBe(archiveFailure) + }) + + it('resolves queued Workspace identities when their operation starts', async () => { + const { controller, ctx, root } = await harness() + const target = await controller.create({ path: stageDir(root, 'target') }) + const blockerPath = stageDir(root, 'blocker') + const gate = deferred() + const originalResolveByPath = ctx.workspaceRegistry.resolveByPath.bind(ctx.workspaceRegistry) + const resolveByPath = vi.spyOn(ctx.workspaceRegistry, 'resolveByPath') + resolveByPath.mockImplementationOnce(async (path) => { + await gate.promise + return originalResolveByPath(path) + }) + + const blocker = controller.create({ path: blockerPath }) + const deletion = controller.delete({ workspaceId: target.workspace.workspaceId }) + const staleRename = controller.rename({ + workspaceId: target.workspace.workspaceId, + title: 'must-not-land', + }) + gate.resolve(undefined) + await blocker + await expect(deletion).resolves.toEqual({ deleted: true }) + await expect(staleRename).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + }) + + it('reorders Workspaces and Sessions and archives only known Sessions', async () => { + const { controller, ctx, root } = await harness() + const first = await controller.create({ path: stageDir(root, 'first') }) + const second = await controller.create({ path: stageDir(root, 'second') }) + await expect(controller.insertBefore({ + workspaceId: first.workspace.workspaceId, + beforeWorkspaceId: second.workspace.workspaceId, + })).resolves.toEqual({ + workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId], + }) + await expect(controller.insertBefore({ workspaceId: 'missing' as WorkspaceId })) + .rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + + const session = ctx.sessions.create(SessionId('session-one'), { + meta: { cwd: first.workspace.path }, + }) + const workspace = ctx.workspaceRegistry.get(first.workspace.workspaceId) + if (workspace === undefined) throw new Error('fixture Workspace disappeared') + await workspace.attachSession(session.id) + await expect(controller.insertSessionBefore({ + workspaceId: first.workspace.workspaceId, + sessionId: session.id, + })).resolves.toMatchObject({ workspace: { sessionIds: [session.id] } }) + await expect(controller.insertSessionBefore({ + workspaceId: first.workspace.workspaceId, + sessionId: SessionId('missing-session'), + })).rejects.toMatchObject({ failure: { code: 'workspace-move-invalid' } }) + await expect(controller.insertSessionBefore({ + workspaceId: first.workspace.workspaceId, + sessionId: session.id, + beforeSessionId: SessionId('missing-anchor'), + })).rejects.toMatchObject({ + failure: { + code: 'workspace-move-invalid', + details: { beforeSessionId: 'missing-anchor' }, + }, + }) + await expect(controller.insertSessionBefore({ + workspaceId: 'missing' as WorkspaceId, + sessionId: session.id, + })).rejects.toMatchObject({ failure: { code: 'workspace-not-found' } }) + + await expect(controller.archiveSession({ sessionId: session.id })) + .resolves.toEqual({ archivedSessionIds: [session.id] }) + await expect(controller.archiveSession({ sessionId: SessionId('unknown') })) + .rejects.toMatchObject({ failure: { code: 'session-not-found' } }) + }) +}) + +describe('WorkspaceController follow', () => { + it('seeds a new feed from existing rows and rejects an inconsistent registry commit', async () => { + const { ctx, root } = await harness() + const existing = await ctx.workspaceRegistry.create(stageDir(root, 'existing')) + const feed = new WorkspaceFeed(ctx) + expect(feed.baseline()).toMatchObject({ + items: [{ workspaceId: existing.id }], + }) + + expect(() => { + ctx.emit('domain/changed', { + domain: 'workspace', + table: '', + key: '', + operation: 'put', + value: { + initialized: true, + workspaceIds: ['missing'], + archivedSessionIds: [], + }, + }) + }).toThrow('references missing Workspace "missing"') + }) + + it('starts with a complete baseline and emits committed increments in domain order', async () => { + const { controller, ctx, root } = await harness() + const abort = new AbortController() + const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]() + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'baseline', + value: { items: [], archivedSessionIds: [] }, + }) + + const first = await controller.create({ path: stageDir(root, 'first') }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: first.workspace.workspaceId }, + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', workspaceIds: [first.workspace.workspaceId], + }) + await controller.rename({ workspaceId: first.workspace.workspaceId, title: 'renamed' }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { title: 'renamed' }, + }) + + const second = await controller.create({ path: stageDir(root, 'second') }) + await expect(nextFrame(iterator)).resolves.toMatchObject({ + type: 'upsert', workspace: { workspaceId: second.workspace.workspaceId }, + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', workspaceIds: [second.workspace.workspaceId, first.workspace.workspaceId], + }) + await controller.insertBefore({ + workspaceId: first.workspace.workspaceId, + beforeWorkspaceId: second.workspace.workspaceId, + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', + workspaceIds: [first.workspace.workspaceId, second.workspace.workspaceId], + }) + + const session = ctx.sessions.create(SessionId('archived'), { + meta: { cwd: first.workspace.path }, + }) + await controller.archiveSession({ sessionId: session.id }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'archived', archivedSessionIds: [session.id], + }) + await controller.delete({ workspaceId: second.workspace.workspaceId }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'order', workspaceIds: [first.workspace.workspaceId], + }) + await expect(nextFrame(iterator)).resolves.toEqual({ + type: 'remove', workspaceId: second.workspace.workspaceId, + }) + + abort.abort() + await expect(iterator.next()).resolves.toEqual({ done: true, value: undefined }) + }) + + it('ignores unrelated domain writes and closes active followers on disposal', async () => { + const { controller, ctx, root } = await harness() + const abort = new AbortController() + const iterator = controller.follow(abort.signal)[Symbol.asyncIterator]() + await nextFrame(iterator) + ctx.emit('domain/changed', { + domain: 'other', table: 'records', key: 'x', operation: 'put', value: {}, + }) + ctx.emit('domain/changed', { + domain: 'workspace', table: '', key: '', operation: 'deleted', + }) + ctx.emit('domain/changed', { + domain: 'workspace', table: 'other', key: 'x', operation: 'put', value: {}, + }) + ctx.emit('domain/changed', { + domain: 'workspace', table: 'workspaces', key: 'unknown', operation: 'deleted', + }) + const pending = iterator.next() + const created = await controller.create({ path: stageDir(root, 'visible') }) + await expect(pending).resolves.toMatchObject({ value: { type: 'upsert' } }) + await expect(iterator.next()).resolves.toEqual({ + done: false, + value: { type: 'order', workspaceIds: [created.workspace.workspaceId] }, + }) + + const closing = iterator.next() + await ctx.fiber.dispose() + roots.splice(roots.indexOf(ctx), 1) + await expect(closing).resolves.toEqual({ done: true, value: undefined }) + }) +}) diff --git a/packages/api/workspace-controller/tsconfig.client.json b/packages/api/workspace-controller/tsconfig.client.json new file mode 100644 index 0000000000..46d392dff3 --- /dev/null +++ b/packages/api/workspace-controller/tsconfig.client.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo" + }, + "files": [ + "src/client/index.ts", + "src/client/model.ts", + "src/client/service.ts", + "src/types.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../gateway/tsconfig.client.json" }, + { "path": "../../client/connection/tsconfig.client.json" }, + { "path": "../../client/store" }, + { "path": "../../core/session" }, + { "path": "../../typert/protocol" }, + { "path": "../../workspace/workspace" } + ] +} diff --git a/packages/api/workspace-controller/tsconfig.host.json b/packages/api/workspace-controller/tsconfig.host.json new file mode 100644 index 0000000000..76c584fd01 --- /dev/null +++ b/packages/api/workspace-controller/tsconfig.host.json @@ -0,0 +1,23 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types", + "tsBuildInfoFile": "lib/tsconfig.host.tsbuildinfo" + }, + "files": [ + "src/index.ts", + "src/invariant.ts", + "src/types.ts", + "src/commands.ts", + "src/feed.ts" + ], + "references": [ + { "path": "../../../vendor/cordis" }, + { "path": "../../core/session" }, + { "path": "../../runtime-diagnostics/invariants" }, + { "path": "../../storage/storage-domain" }, + { "path": "../../typert/protocol" }, + { "path": "../../workspace/workspace" } + ] +} diff --git a/packages/api/workspace-controller/tsconfig.json b/packages/api/workspace-controller/tsconfig.json new file mode 100644 index 0000000000..2a0b0e33f7 --- /dev/null +++ b/packages/api/workspace-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.host.json" }, + { "path": "./tsconfig.client.json" } + ] +} diff --git a/packages/api/workspace-controller/tsdown.config.ts b/packages/api/workspace-controller/tsdown.config.ts new file mode 100644 index 0000000000..7bc3021981 --- /dev/null +++ b/packages/api/workspace-controller/tsdown.config.ts @@ -0,0 +1,7 @@ +import { clientBundle } from '../../client/tsdown.client.ts' + +export default clientBundle( + '@deepseek-ai/dsh-api-workspace-controller', + ['lib/types/index.js', 'lib/types/invariant.js'], + { hostPhase: true }, +) diff --git a/packages/attachment/README.i18n.yaml b/packages/attachment/README.i18n.yaml index 9db0a63e47..1c6b007c86 100644 --- a/packages/attachment/README.i18n.yaml +++ b/packages/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/README.md -README.md: 61b4e5c602f475f85bbe859b8483e30b518e06c8 -README.zh.md: ac93f4870a714d3131fc47789dac9cb5ae926b66 +README.md: c1afde5f4bd44371fdc5417ad087456dfaa4f054 +README.zh.md: 568d9dc93b7a60d9c346fc8d9cd931d92bdecf35 diff --git a/packages/attachment/README.md b/packages/attachment/README.md index 61b4e5c602..c1afde5f4b 100644 --- a/packages/attachment/README.md +++ b/packages/attachment/README.md @@ -1,12 +1,51 @@ -# attachment/ - durable attachment capability family +--- +description: "Package map for the durable image attachment capability family: what you can do with image attachments, and where your images are stored." +kind: "package-group" +--- + +# attachment/ — durable attachment capability family English | [中文](README.zh.md) -The durable binary attachment seam and its local filesystem implementation. Both are product packages. +## Summary + +The `attachment/` group provides durable image attachments: attach images to prompts and commands, and the harness saves them on your machine, shows them again in conversation history, and sends them to the model in later turns. The shipped `dsh` composition enables this with no setup. The capability and its storage are split across two packages, described below. Stored images survive restarts and are never deleted automatically, and only raster image formats are supported. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +These two packages provide durable image attachments; each README describes what you can do with its part. | Package | Role | ctx key | |---|---|---| -| `attachment/` | Immutable attachment references, image limits, and storage service | `ctx.attachments` | -| `attachment-local/` | Content-addressed private storage below `DSH_HOME` | (registers on `ctx.attachments`) | +| [`attachment/`](attachment/README.md) | Image attachments for prompts and commands that persist and come back in history | `ctx.attachments` | +| [`attachment-local/`](attachment-local/README.md) | Stores your attached images on this machine below `DSH_HOME` | registers on `ctx.attachments` | -Unsent browser drafts are intentionally outside this capability. Bytes enter durable storage only when a user prompt is submitted or when a provider adapter commits structured model output. +----- + + +## Related documentation + +Start with the subsystem reference for the service contract, then the capability-seam table and the configuration surface of the local backend. + +- [Attachment subsystem reference](../../docs/subsystems/attachment.md) — service contract, payload types, and the `ctx.attachments` cordis surface. +- [Capability seams](../../docs/capability-seams.md) — the Service Definition / Service Provider / Consumer split this family follows. +- [Generated configuration catalog](../../docs/config-catalog.md#deepseek-aidsh-attachment-local) — every accepted field of the local backend. + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/attachment/README.zh.md b/packages/attachment/README.zh.md index ac93f4870a..568d9dc93b 100644 --- a/packages/attachment/README.zh.md +++ b/packages/attachment/README.zh.md @@ -1,12 +1,51 @@ +--- +description: "持久图片附件能力族的包映射:你可以用图片附件做什么,以及你的图片存放在哪里。" +kind: "package-group" +--- + # attachment/:持久附件能力族 [English](README.md) | 中文 -持久二进制附件 seam 及其本地文件系统实现。两者均为产品包。 +## 概述 + +`attachment/` 组提供持久图片附件:把图片附加到提示词和命令,harness 会把它保存到你的机器上,重新显示在对话历史中,并在后续轮次发送给模型。随附的 `dsh` 组合无需任何设置即可支持这一点。该能力与它的存储拆分为两个包,见下文。已存储的图片在重启后依然存在且永远不会被自动删除,并且只支持光栅图片格式。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +这两个包提供持久图片附件;每个 README 描述其各自部分可以做什么。 | 包 | 角色 | ctx 键 | |---|---|---| -| `attachment/` | 不可变附件引用、图片限制和存储服务 | `ctx.attachments` | -| `attachment-local/` | `DSH_HOME` 下的私有内容寻址存储 | (注册至 `ctx.attachments`) | +| [`attachment/`](attachment/README.zh.md) | 可用于提示词与命令、会持久保存并回到历史中的图片附件 | `ctx.attachments` | +| [`attachment-local/`](attachment-local/README.zh.md) | 把附加图片存储在本机 `DSH_HOME` 下 | 注册到 `ctx.attachments` | -未发送的浏览器草稿刻意位于这项能力之外。只有用户提交提示词,或提供方适配器提交结构化模型输出时,字节才进入持久存储。 +----- + + +## 相关文档 + +先从子系统参考了解服务约定,再看能力 seam 表与本地后端的配置面。 + +- [附件子系统参考](../../docs/subsystems/attachment.zh.md)——服务约定、载荷类型与 `ctx.attachments` 的 cordis 接口面。 +- [能力 seam](../../docs/capability-seams.zh.md)——本家族遵循的 Service Definition / Service Provider / Consumer 拆分。 +- [生成配置目录](../../docs/config-catalog.zh.md#deepseek-aidsh-attachment-local)——本地后端的每个受支持字段。 + + +## 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/attachment/attachment-local/README.i18n.yaml b/packages/attachment/attachment-local/README.i18n.yaml index 1d7c63c469..f3250efc09 100644 --- a/packages/attachment/attachment-local/README.i18n.yaml +++ b/packages/attachment/attachment-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment-local/README.md -README.md: e4f2d5748768a1dc2a6b79c3ed9e364c56a67248 -README.zh.md: 6b548fb993faef996f1508ba9f9efc31b20fea64 +README.md: 2b4c4e17ee8578d63c3f2ef44f7b2c44f116906a +README.zh.md: f44b0cf975b2a86c8e5b7c8ba585701fe88c19f2 diff --git a/packages/attachment/attachment-local/README.md b/packages/attachment/attachment-local/README.md index e4f2d57487..2b4c4e17ee 100644 --- a/packages/attachment/attachment-local/README.md +++ b/packages/attachment/attachment-local/README.md @@ -1,21 +1,150 @@ +--- +description: "Local storage for your attached images below DSH_HOME, for users and maintainers choosing or debugging where image attachments are kept." +kind: "package-reference" +--- + # @deepseek-ai/dsh-attachment-local English | [中文](README.zh.md) -The private local implementation of [`@deepseek-ai/dsh-attachment`](../attachment). Objects land at `/attachments/v1/objects//` and are addressed by an opaque `sha256:` id. Each process proves a home durable once by syncing every ancestor entry to the filesystem root, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then use a private staging directory, owner-only files, a synced temporary file, an atomic exclusive hard-link publish, and directory syncs on the publication path (POSIX; Windows relies on filesystem metadata journaling) so the reported reference survives a crash. Write admission and reads fully decode the raster before accepting its format and dimensions; reads also re-check the digest and logged metadata. Byte, total-pixel, and per-side dimension limits are write-time admission policy, so a later policy reduction does not make already-admitted history unreadable. The per-side default (2000px) stays below the strictest dimension bound deployed model routes enforce on requests carrying many images: an admitted image rides every later request of its session, so admission is the last point where a provider-rejected image can be kept out of durable history. +## Summary -`DSH_HOME` resolves through the shared path policy: explicit config, `$DSH_HOME`, then `~/.dsh`. Session logs contain only the reference and verified metadata, never this host path. `readImage` forwards optional cancellation into the filesystem read, observes it around verification, and preserves it instead of wrapping it as `ATTACHMENT_READ_FAILED`. +This package provides the local storage and image-processing backend for attachments: source images are validated, oriented, stripped of metadata and color profiles, normalized to 8-bit sRGB/sRGBA, and saved below `DSH_HOME`; route-specific request versions are derived and cached separately. It is what the shipped `dsh` composition uses, so durable image attachments work without configuration. Identical normalized images are stored only once, concurrent reads of one request variant share work, and stored images stay readable after later admission-limit changes. Storage is local to this machine — other hosts cannot read these images — and objects are never deleted automatically. +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +In the default composition, attach images to a prompt or command and they are stored on this machine automatically. If you compose your own setup, mounting this one plugin gives you durable image attachments. + +### Minimal configuration + +Mount the plugin with no required configuration. The defaults below define what you can attach; the generated configuration catalog is the exhaustive source for every field. + +```yaml +- name: '@deepseek-ai/dsh-attachment-local' +``` + +| Field | Default | Meaning | +|---|---|---| +| `dshHome` | resolved | Explicit harness home; omitted follows `$DSH_HOME`, then `~/.dsh` | +| `maxImageBytes` | `20 MiB` | Maximum encoded source bytes accepted for one image | +| `maxImagesPerMessage` | `20` | Maximum image count accepted in one submitted message | +| `maxMessageImageBytes` | `200 MiB` | Maximum aggregate encoded source bytes in one submitted message | +| `maxImagePixels` | `64,000,000` | Maximum source width multiplied by height | +| `maxImageDimension` | `8192` | Maximum source width or height | +| `normalizedImageMaxPixels` | `2048 × 2048` | Total-pixel budget of the stored normalized image | +| `normalizedImageMaxDimension` | `8192` | Maximum long edge after applying the total-pixel budget | +| `normalizedImageMaxBytes` | `4 MiB` | Encoded-byte target; the smallest quality-ladder output is kept when none fits | +| `imageCompressionConcurrency` | `2` | FIFO limit for concurrent normalization and request transforms | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-attachment-local) is the exhaustive source for every accepted field and its JSDoc. + +### Where your images are stored and how long they last + +Attached images are kept below `/attachments/v1` on this machine. Stored images are never deleted automatically, identical images are stored only once, and a later tightening of the limits never makes already-saved images unreadable. If your images must be readable from another machine, this package is not the right fit. + +### What happens when you attach an image + +Attach an image and its source limits, media, dimensions, and pixels are checked before it is normalized and saved. EXIF orientation is applied, metadata and color profiles are removed, transparency is preserved, and the raster is reduced under a total-pixel budget plus a long-edge cap. Alpha images use WebP and opaque images use JPEG on the shared 85/75/60 quality ladder; the smallest output is retained when every candidate exceeds the byte target. An accepted image reappears in history and later turns, including after restart; the selected model route receives a cached request version and, when its filesystem maps the host object, a read-only execution-world path. + +### What can go wrong + +An image can be refused when you attach it: unsupported format, over the byte, pixel, or per-side dimension limits, or bytes that do not match their declared type. On a later read, an image that was deleted or corrupted on disk fails with a clear error. Each failure carries a stable code so the client and protocol adapters can explain it in their own words. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains the durability and verification design behind the storage, and the write and read paths that realize it; observable behavior is fully covered in [Use this package](#use-this-package). + +### Design decisions + +- **Durability by fsync chain, not existence.** A synced file alone does not survive a crash when its directory entry never reached storage, so the write path syncs every ancestor entry to a process-proven boundary before a reference can reach a session checkpoint. +- **Normalize once, project per route.** Admission persists one provider-independent normalized attachment; request projection derives deterministic variants without rewriting durable history. +- **Lazy alpha-routed encoding.** Alpha images use WebP and opaque images use JPEG; quality candidates run in 85/75/60 order, and the smallest output is retained when none meets the encoded-byte target. +- **Limits are write-time policy.** Byte, total-pixel, and per-side dimension limits bind admission only, so tightening them later never makes admitted history unreadable. + +### Write and read paths + +Objects land at `/attachments/v1/objects//`; equal bytes deduplicate to one object and one `sha256:` id. Before the first write, the process syncs every ancestor directory of the home down to the filesystem root once, so a directory another process created but has not yet synced is never mistaken for a safe boundary. Writes then stage bytes in `v1/tmp`, sync the temporary file, publish with an atomic exclusive hard link, and sync the publication directories — on Windows, filesystem metadata journaling owns entry durability. Once the save resolves, the reported reference is durable. + +Admission accepts up to 20 images and 200 MiB of source bytes per message; one source may use up to 20 MiB, 64 million pixels, and 8192 pixels per side. It applies orientation, removes metadata and color profiles, and normalizes under a 2048×2048 total-pixel budget, an 8192-pixel long edge, and a 4 MiB encoded-byte target. Extreme aspect ratios therefore retain their short-edge resolution. Clean single-frame 8-bit sRGB/sRGBA PNG, JPEG, or WebP input already within those limits passes through byte-identically; GIF, animation, metadata, orientation, 16-bit PNG, and incompatible color spaces force conversion. + +Request versions live below `/attachments/v1/request-images/`. `readImageRequest` scales without enlargement to a route pixel budget, then applies a separate encoded-byte target through the same alpha routing and quality ladder. Its cache identity includes the attachment id, transform version, budgets, and fixed encoder settings; cached bytes are header-probed for format, 8-bit sRGB/sRGBA, dimensions, and alpha facts, and a mismatch regenerates the entry. Concurrent callers share one transform and cache write, while cancellation stops shared work only when no waiter remains. `imageHostPath` derives the normalized object's host path, and the mounted filesystem may map that path into its execution world without writing it to durable history. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Plugin entry: `LocalAttachmentStore`, `Config` schema, defaults | +| [`src/store.ts`](src/store.ts) | Content-addressed write and verified read: staging, hard-link publish, fsync chain, digest verification | +| [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | Provider-independent normalization and bounded format/quality candidates | +| [`src/request-image.ts`](src/request-image.ts) | Route-specific request transforms, cache identity, and singleflight | +| [`src/image.ts`](src/image.ts) | Full raster decode and metadata verification | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; immutable writes and verified reads enforced at the backend boundary) | + +
+ +----- + + +## Further Exploration + +For the full service contract and payload types, read the subsystem reference; for the capability this storage backs, read the seam package. + +- [Attachment subsystem reference](../../../docs/subsystems/attachment.md) — service contract, payload types, and the `ctx.attachments` cordis surface. +- [Attachment seam package](../attachment/README.md) — the image attachment capability this storage backs. +- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-attachment-local) — every accepted config field and its source declaration. +- [Home paths resolution](../../util/home-paths/README.md) — how `DSH_HOME` resolves from explicit config, environment, and the user home. + +----- + + ## Model Experience -Indirectly, through durable replay of historical user images and structured model image output after restart and fork. +Indirectly, through request descriptors. A mapped execution filesystem lets the model see each image's identity, dimensions, media type, read-only process path, writable-copy extension, and normalization warning alongside the request bytes. #### KV Cache effect -None beyond the image block owned by the requesting adapter. +Normalization and request projection are deterministic. An unchanged attachment and route policy reuse identical cached request bytes on later turns; execution-world path mapping can change descriptor text without changing those bytes or their `variantId`. ## Known Limitations and Deferred Work -- Objects are retained indefinitely; reference-aware garbage collection is deferred. -- The local backend assumes the host and provider adapter share this filesystem service. -- Animated GIF metadata is validated from the logical screen; frame-level decoding policy is provider-owned. + + + +These limits describe what this storage can and cannot do; they are current package constraints. + +- **Images are kept forever** — stored images are never deleted automatically, and nothing collects unreferenced objects. +- **Local to this machine** — images live on the machine that runs the harness; other hosts cannot read them. +- **Animated GIF becomes static** — normalization retains only the first frame; animation is outside the version-one image contract. +- **Encoder output is versioned** — the installed Sharp/libvips build pins normalization and request bytes; an encoder or transform-version upgrade re-addresses future variants while existing objects remain valid. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is working context for maintainers: undecided directions and open questions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above and the package code. + +#### Future: retention and remote storage + +Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects, and a backend serving remote runtimes or shared storage would need its own durability proof. Both directions are undecided; the local storage currently retains every object under `DSH_HOME`. + +
diff --git a/packages/attachment/attachment-local/README.zh.md b/packages/attachment/attachment-local/README.zh.md index 6b548fb993..f44b0cf975 100644 --- a/packages/attachment/attachment-local/README.zh.md +++ b/packages/attachment/attachment-local/README.zh.md @@ -1,21 +1,150 @@ +--- +description: "DSH_HOME 下附加图片的本地存储,供用户与维护者选择或排查图片附件的存放位置。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-attachment-local [English](README.md) | 中文 -这是 [`@deepseek-ai/dsh-attachment`](../attachment) 的私有本地实现。对象存放在 `/attachments/v1/objects//`,并通过不透明的 `sha256:` 标识符寻址。每个进程都会通过将每个祖先目录项逐级同步到文件系统根目录,为某个 home 一次性证明其持久性,因此绝不会把另一个进程已经创建但尚未同步的目录误认为安全边界。随后,写入过程使用私有暂存目录、仅所有者可访问的文件、经过同步的临时文件、原子且排他的硬链接发布,并对发布路径执行目录同步(适用于 POSIX;Windows 依赖文件系统元数据日志),确保已报告的引用能够在崩溃后继续存在。写入准入与读取都会完整解码光栅图片,之后才接受其格式和尺寸;读取还会重新校验摘要和已记录的元数据。字节、总像素和单边尺寸限制属于写入时的准入策略,因此后续收紧限制不会导致已经接纳的历史记录变得不可读。单边默认值(2000px)低于已部署模型路由对携带多张图片的请求所强制执行的最严格尺寸上限:一张已接纳的图片会随会话之后的每次请求发送,准入是把必然被上游拒绝的图片挡在持久历史之外的最后一道关口。 +## 概述 -`DSH_HOME` 按共享路径策略解析:显式配置、`$DSH_HOME`,最后是 `~/.dsh`。会话日志只包含引用和经过校验的元数据,绝不包含这个宿主路径。`readImage` 会把可选取消信号传入文件系统读取、在校验前后观察该信号,并保留取消语义,而不会将其包装成 `ATTACHMENT_READ_FAILED`。 +本包提供附件的本地存储与图片处理后端:源图经过校验、方向修正、元数据与色彩配置移除,并规范化为 8-bit sRGB/sRGBA 后保存在 `DSH_HOME` 下;路由专用请求版本另行派生并缓存。随附的 `dsh` 组合使用的就是它,因此持久图片附件无需配置即可工作。相同规范化图片只存一份,同一请求变体的并发读取共享工作,即使后来收紧准入限制,已存图片仍然可读。存储仅限本机——其他主机无法读取这些图片——对象也永远不会自动删除。 +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +在默认组合中,把图片附加到提示词或命令,它们会自动保存到本机。自行组合时,挂载这一个插件即可获得持久图片附件。 + +### 最小配置 + +挂载插件,无需任何必填配置。下表默认值定义你可以附加什么;生成的配置目录是每个字段的穷尽式真源。 + +```yaml +- name: '@deepseek-ai/dsh-attachment-local' +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `dshHome` | 自动解析 | 显式 harness home;省略时依次跟随 `$DSH_HOME` 与 `~/.dsh` | +| `maxImageBytes` | `20 MiB` | 单张图片接受的最大编码源字节数 | +| `maxImagesPerMessage` | `20` | 单条提交消息接受的最大图片数量 | +| `maxMessageImageBytes` | `200 MiB` | 单条提交消息接受的最大编码源图字节总数 | +| `maxImagePixels` | `64,000,000` | 源图接受的最大宽度乘以高度 | +| `maxImageDimension` | `8192` | 源图接受的最大宽度或高度 | +| `normalizedImageMaxPixels` | `2048 × 2048` | 已存规范化图片的总像素预算 | +| `normalizedImageMaxDimension` | `8192` | 应用总像素预算后的最大长边 | +| `normalizedImageMaxBytes` | `4 MiB` | 编码字节目标;没有候选满足时保留质量阶梯中的最小输出 | +| `imageCompressionConcurrency` | `2` | 并发规范化与请求变换的 FIFO 上限 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-attachment-local)是每个受支持字段及其 JSDoc 的穷尽式真源。 + +### 图片存储在哪里、会保留多久 + +附加的图片保存在本机的 `/attachments/v1` 下。已存储的图片永远不会被自动删除,相同图片只会存储一份,之后收紧限制也绝不会让已保存的图片不可读。如果你的图片需要能从另一台机器读取,本包并不合适。 + +### 附加图片时会发生什么 + +附加图片后,会先检查源图限制、媒体类型、尺寸与像素,再完成规范化并保存。系统应用 EXIF 方向、移除元数据与色彩配置、保留透明度,并按总像素预算与长边上限缩小光栅。带 alpha 的图片使用 WebP,不透明图片使用 JPEG,共享 85/75/60 质量阶梯;全部候选都超过字节目标时保留最小输出。被接受的图片会重新出现在历史和后续轮次中,重启后也不例外;所选模型路由会收到缓存的请求版本,并在其文件系统可映射宿主对象时收到只读执行世界路径。 + +### 可能出什么问题 + +附加图片时可能被拒绝:格式不受支持、超出字节、像素或单边尺寸限制,或者字节与声明类型不符。之后读取时,磁盘上被删除或损坏的图片会以明确错误失败。每个失败都带有稳定错误码,客户端与协议适配器可以用自己的措辞解释。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释存储背后的持久性与校验设计,以及实现它的写入与读取路径;可观察行为已在[使用本包](#use-this-package)中完整说明。 + +### 设计决策 + +- **持久性靠 fsync 链,而非存在性。** 当目录项从未到达存储时,仅同步文件无法在崩溃后存活,因此写入路径会在引用可能到达会话检查点前,把每个祖先条目同步到进程已验证的边界。 +- **一次规范化,按路由投影。** 准入持久保存一份提供方无关的规范化附件;请求投影派生确定性变体而不改写持久历史。 +- **惰性 alpha 路由编码。** 带 alpha 的图片使用 WebP,不透明图片使用 JPEG;质量候选按 85/75/60 顺序运行,没有候选满足编码字节目标时保留最小输出。 +- **限制是写入时策略。** 字节、总像素与单边尺寸限制只约束准入,因此之后收紧它们绝不会让已接纳的历史不可读。 + +### 写入与读取路径 + +对象存放在 `/attachments/v1/objects//`;相同字节会去重为同一个对象和同一个 `sha256:` 标识符。首次写入前,进程会把 home 的每个祖先目录逐级同步到文件系统根目录,因此绝不会把另一个进程已创建但尚未同步的目录误认为安全边界。随后,写入过程把字节暂存到 `v1/tmp`、同步临时文件、以原子且排他的硬链接发布,并同步发布目录——在 Windows 上,文件系统元数据日志负责目录项持久性。保存成功后,已报告的引用即持久。 + +准入允许每条消息最多 20 张图片与 200 MiB 源字节;单个源图最多 20 MiB、6400 万像素与单边 8192 像素。系统应用方向、移除元数据与色彩配置,并把规范化结果限制在 2048×2048 总像素预算、8192 像素长边和 4 MiB 编码字节目标内,因此极端宽高比会保留短边分辨率。已经满足限制的干净、单帧、8-bit sRGB/sRGBA PNG、JPEG 或 WebP 会逐字节直通;GIF、动画、元数据、方向、16-bit PNG 与不兼容色彩空间会触发转换。 + +请求版本位于 `/attachments/v1/request-images/`。`readImageRequest` 在不放大的前提下缩放到路由像素预算,再通过相同的 alpha 路由与质量阶梯应用独立编码字节目标。缓存身份包含附件 id、变换版本、预算与固定编码参数;缓存字节会先探测格式、8-bit sRGB/sRGBA、尺寸与 alpha 信息,不匹配时重新生成。并发调用方共享一次变换与缓存写入,且只在没有等待方时由取消停止共享工作。`imageHostPath` 派生规范化对象的宿主路径,挂载的文件系统可以把该路径映射进执行世界,而不会写入持久历史。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 插件入口:`LocalAttachmentStore`、`Config` schema、默认值 | +| [`src/store.ts`](src/store.ts) | 内容寻址写入与校验读取:暂存、硬链接发布、fsync 链、摘要校验 | +| [`src/normalization.ts`](src/normalization.ts) + [`src/encoding.ts`](src/encoding.ts) | 提供方无关的规范化与有界格式/质量候选 | +| [`src/request-image.ts`](src/request-image.ts) | 路由专用请求变换、缓存身份与 singleflight | +| [`src/image.ts`](src/image.ts) | 完整光栅解码与元数据校验 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;不可变写入与校验读取在后端边界直接强制) | + +
+ +----- + + +## 进一步探索 + +完整的服务约定与载荷类型请看子系统参考;这份存储所支撑的能力请看 seam 包。 + +- [附件子系统参考](../../../docs/subsystems/attachment.zh.md)——服务约定、载荷类型与 `ctx.attachments` 的 cordis 接口面。 +- [附件 seam 包](../attachment/README.zh.md)——本存储支撑的图片附件能力。 +- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-attachment-local)——每个受支持配置字段及其源声明。 +- [Home 路径解析](../../util/home-paths/README.zh.md)——`DSH_HOME` 如何从显式配置、环境变量与用户主目录解析。 + +----- + + ## 模型体验 -该包通过重启和 fork 后对历史用户图片与结构化模型图片输出的持久回放间接影响模型。 +本包通过请求描述符间接影响模型。执行文件系统可以映射宿主对象时,模型会随请求字节看到每张图片的身份、尺寸、媒体类型、只读进程路径、可写副本扩展名与规范化警告。 -#### KV 缓存影响 +#### KV Cache 影响 -除发起请求的适配器所持有的图片块外,不产生其他影响。 +规范化和请求投影都是确定性的。附件和路由策略不变时,之后各轮会复用相同的缓存请求字节;执行世界路径映射可以改变描述符文本,而不会改变这些字节或其 `variantId`。 -## 已知限制与待完成工作 +## 已知限制与延期工作 -- 对象会无限期保留;基于引用的垃圾回收尚未实现。 -- 本地后端假定宿主与提供方适配器共享同一个文件系统服务。 -- 动态 GIF 的元数据根据逻辑屏幕进行校验;逐帧解码策略由提供方持有。 + + + +这些限制描述了这份存储能做什么、不能做什么;它们是当前包约束。 + +- **图片会永久保留**——已存储的图片永远不会被自动删除,也没有任何机制回收未被引用的对象。 +- **仅限本机**——图片存放在运行 harness 的机器上;其他主机无法读取。 +- **动态 GIF 变为静态**——规范化只保留第一帧;动画不属于版本一图片约定。 +- **编码器输出带版本**——已安装的 Sharp/libvips 构建钉定规范化与请求字节;编码器或变换版本升级会让未来变体产生新地址,已有对象继续有效。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +本开发备注是维护者的工作上下文:尚未决定的探索方向与开放问题。它明确不具权威性——已交付的行为与限制以上文和包代码为准。 + +#### 未来:保留与远程存储 + +保留与垃圾回收被推迟,因为恢复和 fork 后的会话可能共享不可变对象;服务于远程运行时或共享存储的后端则需要自己的持久性证明。两个方向都尚未决定;本地存储当前在 `DSH_HOME` 下保留所有对象。 + +
diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 176a728da9..194112be29 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment-local/src/compression-limiter.ts b/packages/attachment/attachment-local/src/compression-limiter.ts new file mode 100644 index 0000000000..3935f262a1 --- /dev/null +++ b/packages/attachment/attachment-local/src/compression-limiter.ts @@ -0,0 +1,43 @@ +/** Instance-owned concurrency bound for native image transformations. */ + +/** FIFO limiter for asynchronous compression work. */ +export class CompressionLimiter { + private active = 0 + private readonly waiting: Array<() => void> = [] + + /** + * @param concurrency - positive maximum number of active tasks. + */ + constructor(readonly concurrency: number) {} + + /** + * Run one task after an instance slot becomes available. + * @param task - compression operation occupying one slot until settlement. + * @returns the task result. + */ + run(task: () => Promise): Promise { + return new Promise((resolve, reject) => { + const start = (): void => { + this.active += 1 + const release = (): void => { + this.active -= 1 + this.waiting.shift()?.() + } + void Promise.resolve().then(task).then( + (value) => { + release() + resolve(value) + }, + (error: unknown) => { + release() + reject(error instanceof Error + ? error + : new Error('Image compression task rejected with a non-Error value.', { cause: error })) + }, + ) + } + if (this.active < this.concurrency) start() + else this.waiting.push(start) + }) + } +} diff --git a/packages/attachment/attachment-local/src/encoding.ts b/packages/attachment/attachment-local/src/encoding.ts new file mode 100644 index 0000000000..1b16aafb30 --- /dev/null +++ b/packages/attachment/attachment-local/src/encoding.ts @@ -0,0 +1,83 @@ +/** Shared quality ladder and lazy candidate execution for normalization and request-image encoders. */ + +import type { Sharp } from 'sharp' + +/** Shared ladder for both encoders: spaced so each step buys a real size reduction. */ +export const IMAGE_ENCODING_QUALITIES = [85, 75, 60] as const +/** Fixed lossy-WebP effort; deeper search costs 3-4x encode time for about 5% size. */ +export const WEBP_ENCODING_EFFORT = 0 + +/** One ladder output carrying its complete bytes and exact facts. */ +export interface EncodedImage { + data: Uint8Array + mediaType: 'image/jpeg' | 'image/webp' + width: number + height: number +} + +async function encode(pipeline: Sharp, mediaType: EncodedImage['mediaType'], quality: number): Promise { + const encoded = mediaType === 'image/webp' + ? pipeline.webp({ quality, effort: WEBP_ENCODING_EFFORT }) + : pipeline.jpeg({ quality }) + const { data, info } = await encoded.toBuffer({ resolveWithObject: true }) + return { data: new Uint8Array(data), mediaType, width: info.width, height: info.height } +} + +/** + * Build the lazy quality ladder for one prepared pipeline: WebP keeps a source + * alpha channel, everything else is JPEG. + * @param prepared - sized sRGB pipeline; cloned per candidate. + * @param hasAlpha - decoded source alpha fact selecting the codec. + * @returns encoders ordered from highest to lowest ladder quality. + */ +export function encodingLadder(prepared: Sharp, hasAlpha: boolean): Array<() => Promise> { + const mediaType = hasAlpha ? 'image/webp' : 'image/jpeg' + return IMAGE_ENCODING_QUALITIES.map(quality => ( + () => encode(prepared.clone(), mediaType, quality) + )) +} + +/** One encoded candidate carrying its complete bytes. */ +export interface EncodedCandidate { + data: Uint8Array +} + +/** Result of exhausting candidates at one raster size without a fitting output. */ +export interface ExhaustedEncoding { + smallest: T +} + +/** + * Execute encoding candidates in preference order and stop after the first fitting output. + * @param attempts - lazy encoders ordered from preferred to fallback representation. + * @param maxBytes - positive encoded-byte target. + * @returns the first fitting candidate, otherwise the smallest completed fallback. + */ +export async function encodeFirstWithinLimit( + attempts: readonly (() => Promise)[], + maxBytes: number, +): Promise> { + const [first, ...remaining] = attempts + if (first === undefined) throw new Error('image encoding requires at least one candidate') + let smallest = await first() + if (smallest.data.byteLength <= maxBytes) return smallest + for (const attempt of remaining) { + const candidate = await attempt() + if (candidate.data.byteLength <= maxBytes) return candidate + if (candidate.data.byteLength < smallest.data.byteLength) { + smallest = candidate + } + } + return { smallest } +} + +/** + * Whether a lazy encoding result exhausted every candidate at one size. + * @param result - first fitting candidate or exhausted result. + * @returns whether every candidate exceeded the byte target. + */ +export function isExhaustedEncoding( + result: T | ExhaustedEncoding, +): result is ExhaustedEncoding { + return 'smallest' in result +} diff --git a/packages/attachment/attachment-local/src/image.ts b/packages/attachment/attachment-local/src/image.ts index b067ea80ff..c34944f676 100644 --- a/packages/attachment/attachment-local/src/image.ts +++ b/packages/attachment/attachment-local/src/image.ts @@ -7,8 +7,38 @@ import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' /** Decoded metadata from a supported image. */ export interface DetectedImage { mediaType: ImageMediaType + /** Intrinsic width with EXIF orientation applied — the width a viewer perceives. */ width: number + /** Intrinsic height with EXIF orientation applied — the height a viewer perceives. */ height: number + /** Whether the container carries more than one frame. */ + animated: boolean + /** Whether the bytes carry descriptive metadata, a color profile, or orientation. */ + carriesMetadata: boolean + /** Sharp sample depth reported for the decoded channels. */ + depth: string + /** Sharp colour space reported for the decoded pixels. */ + space: string + /** Whether decoded pixels carry an alpha channel. */ + hasAlpha: boolean +} + +/** + * Check alpha metadata for bytes produced by this package's encoders. + * Sharp/libvips may omit an all-opaque alpha plane from WebP output; every + * other addition or removal indicates that the encoded result is incompatible + * with its source facts. + * @param sourceHasAlpha - whether the source bytes declare an alpha plane, or undefined when the source frame is unspecified. + * @param output - decoded media type and alpha metadata from the encoded result. + * @returns whether the output alpha metadata is compatible with the source. + */ +export function encodedAlphaIsCompatible( + sourceHasAlpha: boolean | undefined, + output: Pick, +): boolean { + return sourceHasAlpha === undefined + || output.hasAlpha === sourceHasAlpha + || (sourceHasAlpha && !output.hasAlpha && output.mediaType === 'image/webp') } const MEDIA_TYPES: Readonly> = { @@ -18,13 +48,36 @@ const MEDIA_TYPES: Readonly> = { gif: 'image/gif', } +function carriesRetainedMetadata(metadata: Awaited>): boolean { + return metadata.exif !== undefined + || metadata.xmp !== undefined + || metadata.iptc !== undefined + || metadata.icc !== undefined + || metadata.hasProfile + || metadata.tifftagPhotoshop !== undefined + || metadata.comments !== undefined + || metadata.orientation !== undefined +} + async function imageMetadata(image: Sharp): Promise { const metadata = await image.metadata() const mediaType = MEDIA_TYPES[metadata.format as string] if (mediaType === undefined) { throw new AttachmentError('Unsupported or malformed image data.', 'INVALID_IMAGE') } - return { mediaType, width: metadata.width, height: metadata.height } + // EXIF orientations 5-8 transpose the stored raster; report the perceived + // axes so limits, source facts, and coordinate advice all share them. + const transposed = metadata.orientation !== undefined && metadata.orientation >= 5 + return { + mediaType, + width: transposed ? metadata.height : metadata.width, + height: transposed ? metadata.width : metadata.height, + animated: (metadata.pages ?? 1) > 1, + carriesMetadata: carriesRetainedMetadata(metadata), + depth: metadata.depth, + space: metadata.space, + hasAlpha: metadata.hasAlpha, + } } /** diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index a4047da1f1..16b963f225 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -4,43 +4,139 @@ import { join, resolve } from 'node:path' import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' -import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import type { + ImageAttachmentLimits, + ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, + SaveImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' -import { readImageFile, saveImageFile, validateImageFile } from './store.ts' +import type { NormalizationPolicy } from './normalization.ts' +import { CompressionLimiter } from './compression-limiter.ts' +import { commitPreparedImageFile, normalizedImagePath, prepareImageFile, readImageFile, validateImageFile } from './store.ts' +import { readRequestImageFile, requestImageVariantId } from './request-image.ts' -export { readImageFile, saveImageFile, validateImageFile } from './store.ts' +export { canPassThroughNormalization, normalizeImage } from './normalization.ts' +export type { NormalizedImage, NormalizationPolicy } from './normalization.ts' +export { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile, validateImageFile } from './store.ts' +export type { PreparedImageFile } from './store.ts' +export { readRequestImageFile, requestImageVariantId } from './request-image.ts' -/** Default maximum encoded bytes for one image. */ -export const DEFAULT_MAX_IMAGE_BYTES = 3.5 * 1024 * 1024 +/** Default maximum encoded bytes for one submitted image; oversized sources are refused, not shrunk. */ +export const DEFAULT_MAX_IMAGE_BYTES = 20 * 1024 * 1024 /** Default maximum images in one prompt. */ export const DEFAULT_MAX_IMAGES_PER_MESSAGE = 20 /** Default maximum aggregate image bytes in one prompt. */ -export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 100 * 1024 * 1024 -/** Default maximum intrinsic pixels for one image. */ -export const DEFAULT_MAX_IMAGE_PIXELS = 40_000_000 +export const DEFAULT_MAX_MESSAGE_IMAGE_BYTES = 200 * 1024 * 1024 +/** Default maximum intrinsic pixels for one submitted image. */ +export const DEFAULT_MAX_IMAGE_PIXELS = 64_000_000 +/** Default per-side pixel cap for one submitted image. */ +export const DEFAULT_MAX_IMAGE_DIMENSION = 8192 /** - * Default maximum intrinsic width and height for one image. Deployed model - * routes reject any request whose history carries an image with a side above - * 2000px once the request holds many images, and an admitted image rides - * every later request of its session, so admission refuses at the same line - * to keep the durable history streamable. + * Default total-pixel budget of the stored normalized image. A larger source + * is admitted and downscaled proportionally, so admission bounds what rides + * every later model request without refusing ordinary large sources; extreme + * aspect ratios keep their short-edge resolution instead of collapsing under + * a long-edge rule. */ -export const DEFAULT_MAX_IMAGE_DIMENSION = 2000 +export const DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS = 2048 * 2048 +/** Default long-edge cap of the stored normalized image, applied after the total-pixel budget. */ +export const DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION = 8192 +/** Default encoded-byte target for one stored normalized image. */ +export const DEFAULT_NORMALIZED_IMAGE_MAX_BYTES = 4 * 1024 * 1024 +/** Conservative default number of simultaneous native image transformations per store. */ +export const DEFAULT_IMAGE_COMPRESSION_CONCURRENCY = 2 +/** Maximum configurable native image transformations per store. */ +export const MAX_IMAGE_COMPRESSION_CONCURRENCY = 8 /** Local attachment backend configuration. */ export interface Config { /** Explicit harness home; omitted follows `DSH_HOME`, then `~/.dsh`. */ dshHome?: string - /** Maximum encoded bytes accepted for one image. */ + /** Maximum encoded bytes accepted for one submitted image. Default: 20 MiB. */ maxImageBytes?: number - /** Maximum image count accepted in one submitted message. */ + /** Maximum image count accepted in one submitted message. Default: 20. */ maxImagesPerMessage?: number - /** Maximum aggregate encoded image bytes accepted in one submitted message. */ + /** Maximum aggregate encoded image bytes accepted in one submitted message. Default: 200 MiB. */ maxMessageImageBytes?: number - /** Maximum intrinsic width multiplied by height accepted for one image. */ + /** Maximum intrinsic width multiplied by height accepted for one submitted image. Default: 64,000,000. */ maxImagePixels?: number - /** Maximum intrinsic width and maximum intrinsic height accepted for one image. */ + /** Maximum intrinsic width and maximum intrinsic height accepted for one submitted image. Default: 8192px. */ maxImageDimension?: number + /** Total-pixel budget of the stored provider-independent normalized image. */ + normalizedImageMaxPixels?: number + /** Long-edge pixel cap of the stored provider-independent normalized image, applied after the total-pixel budget. */ + normalizedImageMaxDimension?: number + /** + * Encoded-byte target of the stored provider-independent normalized image; + * the smallest quality-ladder output is kept when no quality fits. + */ + normalizedImageMaxBytes?: number + /** Maximum simultaneous normalization or request-image transformations in this service instance. */ + imageCompressionConcurrency?: number +} + +function abortReason(signal: AbortSignal): Error { + const reason: unknown = signal.reason + return reason instanceof Error + ? reason + : new Error('Attachment request cancelled with a non-Error reason.', { cause: reason }) +} + +class SharedRequest { + readonly controller = new AbortController() + readonly promise: Promise + private settled = false + private waiters = 0 + + constructor(start: (signal: AbortSignal) => Promise) { + this.promise = start(this.controller.signal).finally(() => { + this.settled = true + }) + } + + wait(signal?: AbortSignal): Promise { + signal?.throwIfAborted() + this.waiters += 1 + if (signal === undefined) { + return this.promise.finally(() => { + this.release(false) + }) + } + let released = false + const release = (cancelled: boolean): void => { + if (released) return + released = true + this.release(cancelled, signal) + } + return new Promise((resolve, reject) => { + const abort = (): void => { + release(true) + reject(abortReason(signal)) + } + signal.addEventListener('abort', abort, { once: true }) + void this.promise.then((value) => { + signal.removeEventListener('abort', abort) + release(false) + resolve(value) + }, (error: unknown) => { + signal.removeEventListener('abort', abort) + release(false) + // CompressionLimiter normalizes task rejections before this handler. + // oxlint-disable-next-line typescript/prefer-promise-reject-errors + reject(error) + }) + }) + } + + private release(cancelled: boolean, signal?: AbortSignal): void { + this.waiters -= 1 + if (cancelled && this.waiters === 0 && !this.settled && signal !== undefined) { + this.controller.abort(abortReason(signal)) + } + } } /** Persistent content-addressed local attachment store. */ @@ -52,11 +148,22 @@ export class LocalAttachmentStore extends AttachmentStore { maxMessageImageBytes: z.number().step(1).min(1).default(DEFAULT_MAX_MESSAGE_IMAGE_BYTES), maxImagePixels: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_PIXELS), maxImageDimension: z.number().step(1).min(1).default(DEFAULT_MAX_IMAGE_DIMENSION), + normalizedImageMaxPixels: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS), + normalizedImageMaxDimension: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION), + normalizedImageMaxBytes: z.number().step(1).min(1).default(DEFAULT_NORMALIZED_IMAGE_MAX_BYTES), + imageCompressionConcurrency: z.number().step(1).min(1).max(MAX_IMAGE_COMPRESSION_CONCURRENCY) + .default(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY), }) /** Absolute versioned storage root. */ readonly root: string readonly imageLimits: ImageAttachmentLimits + /** Resolved provider-independent normalization policy. */ + readonly normalizationPolicy: Readonly + /** Resolved instance-level compression limit. */ + readonly imageCompressionConcurrency: number + private readonly compression: CompressionLimiter + private readonly requestInflight = new Map>() constructor(ctx: Context, config: Config) { super(ctx) @@ -69,19 +176,93 @@ export class LocalAttachmentStore extends AttachmentStore { maxImageDimension: config.maxImageDimension ?? DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: Object.freeze(['image/png', 'image/jpeg', 'image/webp', 'image/gif'] as const), }) + this.normalizationPolicy = Object.freeze({ + maxPixels: config.normalizedImageMaxPixels ?? DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, + maxDimension: config.normalizedImageMaxDimension ?? DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + maxBytes: config.normalizedImageMaxBytes ?? DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, + }) + const compressionConcurrency = config.imageCompressionConcurrency ?? DEFAULT_IMAGE_COMPRESSION_CONCURRENCY + if (!Number.isSafeInteger(compressionConcurrency) + || compressionConcurrency < 1 + || compressionConcurrency > MAX_IMAGE_COMPRESSION_CONCURRENCY) { + throw new Error( + `attachment-local: imageCompressionConcurrency must be an integer from 1 through ${MAX_IMAGE_COMPRESSION_CONCURRENCY}`, + ) + } + this.imageCompressionConcurrency = compressionConcurrency + this.compression = new CompressionLimiter(compressionConcurrency) } async validateImage(input: SaveImageAttachment): Promise { - await validateImageFile(input, this.imageLimits) + await this.compression.run(() => validateImageFile(input, this.imageLimits, this.normalizationPolicy)) + } + + override async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + this.validateImageBatch(inputs) + const prepared = await Promise.all(inputs.map(input => this.compression.run( + () => prepareImageFile(input, this.imageLimits, this.normalizationPolicy), + ))) + const refs: ImageAttachmentRef[] = [] + for (const image of prepared) refs.push(await commitPreparedImageFile(this.root, image)) + return refs } async saveImage(input: SaveImageAttachment): Promise { - return saveImageFile(this.root, input, this.imageLimits) + const prepared = await this.compression.run( + () => prepareImageFile(input, this.imageLimits, this.normalizationPolicy), + ) + return commitPreparedImageFile(this.root, prepared) } async readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise { return readImageFile(this.root, ref, signal) } + + override imageHostPath(ref: ImageAttachmentRef): string { + return normalizedImagePath(this.root, ref) + } + + override async readImageRequest( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + return this.requestVersion(ref, policy, undefined, signal) + } + + private requestVersion( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + stored: StoredImageAttachment | undefined, + signal: AbortSignal | undefined, + ): Promise { + signal?.throwIfAborted() + const variantId = requestImageVariantId(ref, policy) + const key = String(variantId) + let operation = this.requestInflight.get(key) + if (operation?.controller.signal.aborted) { + this.requestInflight.delete(key) + operation = undefined + } + if (operation === undefined) { + const shared = new SharedRequest(sharedSignal => this.compression.run(async () => { + const request = await readRequestImageFile( + this.root, + stored ?? await this.readImage(ref, sharedSignal), + policy, + sharedSignal, + ) + return request + })) + operation = shared + this.requestInflight.set(key, shared) + void shared.promise.finally(() => { + if (this.requestInflight.get(key) === shared) this.requestInflight.delete(key) + }).catch(() => {}) + } + return operation.wait(signal) + } + } export default LocalAttachmentStore diff --git a/packages/attachment/attachment-local/src/normalization.ts b/packages/attachment/attachment-local/src/normalization.ts new file mode 100644 index 0000000000..7d514786a2 --- /dev/null +++ b/packages/attachment/attachment-local/src/normalization.ts @@ -0,0 +1,130 @@ +/** Deterministic provider-independent image normalization. */ + +import sharp, { type Sharp } from 'sharp' +import { AttachmentError, requestImageDimensions } from '@deepseek-ai/dsh-attachment' +import type { ImageMediaType } from '@deepseek-ai/dsh-attachment' +import { encodeFirstWithinLimit, encodingLadder, isExhaustedEncoding } from './encoding.ts' +import { detectImage, encodedAlphaIsCompatible } from './image.ts' +import type { DetectedImage } from './image.ts' + +/** Deployment-resolved policy for the persisted normalized attachment. */ +export interface NormalizationPolicy { + /** Total-pixel budget; larger sources are downscaled proportionally. */ + maxPixels: number + /** Long-edge cap in pixels applied after the total-pixel budget, bounding extreme aspect ratios. */ + maxDimension: number + /** Encoded-byte target for the quality ladder; the smallest ladder output is kept when no quality fits. */ + maxBytes: number +} + +/** Normalized bytes beside the facts recorded by a durable reference. */ +export interface NormalizedImage { + data: Uint8Array + mediaType: ImageMediaType + width: number + height: number +} + +/** + * Whether bytes already satisfy the normalization requirements. + * @param detected - fully decoded source facts. + * @param bytes - encoded source length. + * @param policy - resolved normalization limits. + * @returns whether the source can pass through byte-identically. + */ +export function canPassThroughNormalization( + detected: DetectedImage, + bytes: number, + policy: NormalizationPolicy, +): boolean { + return detected.mediaType !== 'image/gif' + && !detected.animated + && !detected.carriesMetadata + && detected.depth === 'uchar' + && detected.space === 'srgb' + && bytes <= policy.maxBytes + && detected.width * detected.height <= policy.maxPixels + && Math.max(detected.width, detected.height) <= policy.maxDimension +} + +/** Assert that a normalized output is an 8-bit sRGB/sRGBA single-frame image with matching facts. */ +async function verifyNormalizedImage( + image: NormalizedImage, + expectedAlpha: boolean | undefined, +): Promise { + const detected = await detectImage(image.data) + if (detected.mediaType !== image.mediaType + || detected.width !== image.width + || detected.height !== image.height + || detected.animated + || detected.carriesMetadata + || detected.depth !== 'uchar' + || detected.space !== 'srgb' + || !encodedAlphaIsCompatible(expectedAlpha, detected)) { + throw new AttachmentError( + 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', + 'ATTACHMENT_WRITE_FAILED', + ) + } + return image +} + +/** Build one fixed-size, oriented, metadata-free sRGB pipeline from submitted bytes. */ +function preparedPipeline(data: Uint8Array, width: number, height: number): Sharp { + return sharp(data, { failOn: 'error', limitInputPixels: false }) + .rotate() + .toColourspace('srgb') + .resize({ width, height, fit: 'inside', withoutEnlargement: true }) +} + +/** Dimensions under the total-pixel budget, then the long-edge cap, without changing aspect ratio. */ +function initialDimensions(detected: DetectedImage, policy: NormalizationPolicy): { width: number; height: number } { + const budgeted = requestImageDimensions(detected.width, detected.height, policy.maxPixels) + const longEdge = Math.max(budgeted.width, budgeted.height) + if (longEdge <= policy.maxDimension) return budgeted + const scale = policy.maxDimension / longEdge + return { + width: Math.max(1, Math.floor(budgeted.width * scale)), + height: Math.max(1, Math.floor(budgeted.height * scale)), + } +} + +/** + * Produce the persisted provider-independent normalized version of one fully decoded source. + * The source is passed through only when it is already clean, single-frame, 8-bit sRGB/sRGBA, + * and inside every normalization limit. Re-encoding never removes transparency. When every + * ladder quality exceeds the byte target, the smallest ladder output is kept; provider byte + * caps stay enforced at the route that transmits the bytes. + * @param data - complete admitted source bytes. + * @param detected - fully decoded source facts. + * @param policy - resolved independent normalization limits. + * @returns verified provider-independent normalized bytes and metadata. + */ +export async function normalizeImage( + data: Uint8Array, + detected: DetectedImage, + policy: NormalizationPolicy, +): Promise { + if (canPassThroughNormalization(detected, data.byteLength, policy)) { + return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height } + } + try { + const { width, height } = initialDimensions(detected, policy) + const encoded = await encodeFirstWithinLimit( + encodingLadder(preparedPipeline(data, width, height), detected.hasAlpha), + policy.maxBytes, + ) + const chosen = isExhaustedEncoding(encoded) ? encoded.smallest : encoded + return await verifyNormalizedImage(chosen, detected.mediaType === 'image/gif' ? undefined : detected.hasAlpha) + } catch (error) { + if (error instanceof AttachmentError) throw error + const source = detected.mediaType === 'image/png' && detected.depth !== 'uchar' + ? `${detected.depth === 'ushort' ? '16-bit' : detected.depth} PNG` + : `${detected.depth} ${detected.mediaType.slice('image/'.length).toUpperCase()}` + throw new AttachmentError( + `The ${source} could not be converted to the normalized 8-bit sRGB form.`, + 'ATTACHMENT_WRITE_FAILED', + { cause: error }, + ) + } +} diff --git a/packages/attachment/attachment-local/src/request-image.ts b/packages/attachment/attachment-local/src/request-image.ts new file mode 100644 index 0000000000..237dc5811f --- /dev/null +++ b/packages/attachment/attachment-local/src/request-image.ts @@ -0,0 +1,207 @@ +/** Deterministic cached image versions for model requests. */ + +import { createHash, randomUUID } from 'node:crypto' +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import sharp, { type Sharp } from 'sharp' +import { AttachmentError, ImageVariantId, requestImageDimensions } from '@deepseek-ai/dsh-attachment' +import type { + ImageMediaType, + ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, + StoredImageAttachment, +} from '@deepseek-ai/dsh-attachment' +import { + IMAGE_ENCODING_QUALITIES, + WEBP_ENCODING_EFFORT, + encodeFirstWithinLimit, + encodingLadder, + isExhaustedEncoding, +} from './encoding.ts' +import { detectImage, encodedAlphaIsCompatible, probeImage } from './image.ts' + +/** Transform version included in every cache and upload-index identity. */ +export const REQUEST_IMAGE_TRANSFORM_VERSION = 'request-image-v5' + +interface EncodedRequestImage { + data: Uint8Array + mediaType: ImageMediaType + width: number + height: number +} + +interface VerifiedRequestImage extends EncodedRequestImage { + hasAlpha: boolean +} + +function digest(value: string | Uint8Array): string { + return createHash('sha256').update(value).digest('hex') +} + +function checkedInteger(value: number, name: string): number { + if (!Number.isSafeInteger(value) || value <= 0) { + throw new AttachmentError(`${name} must be a positive integer.`, 'INVALID_ATTACHMENT_REF') + } + return value +} + +function validatePolicy(policy: ImageRequestPolicy): void { + checkedInteger(policy.maxPixels, 'Image request maxPixels') + checkedInteger(policy.maxBytes, 'Image request maxBytes') +} + +function descriptor(attachment: ImageAttachmentRef, policy: ImageRequestPolicy): string { + return JSON.stringify({ + transformVersion: REQUEST_IMAGE_TRANSFORM_VERSION, + attachmentId: attachment.attachmentId, + routePixelBudget: policy.maxPixels, + encodedByteBudget: policy.maxBytes, + encoding: { + webpQualities: IMAGE_ENCODING_QUALITIES, + webpEffort: WEBP_ENCODING_EFFORT, + jpegQualities: IMAGE_ENCODING_QUALITIES, + order: ['alpha:webp', 'opaque:jpeg'], + colourspace: 'srgb', + }, + }) +} + +/** + * Complete deterministic identity for one attachment and route-owned request policy. + * @param attachment - provider-independent durable normalized attachment reference. + * @param policy - route-owned pixel and byte policy. + * @returns branded digest over every request transform input. + */ +export function requestImageVariantId( + attachment: ImageAttachmentRef, + policy: ImageRequestPolicy, +): ReturnType { + return ImageVariantId(`sha256:${digest(descriptor(attachment, policy))}`) +} + +function pipeline(attachment: StoredImageAttachment, width: number, height: number): Sharp { + return sourcePipeline(attachment) + .resize({ width, height, fit: 'inside', withoutEnlargement: true }) +} + +function sourcePipeline(attachment: StoredImageAttachment): Sharp { + return sharp(attachment.data, { failOn: 'error', limitInputPixels: false }).toColourspace('srgb') +} + +async function createRequestImage( + attachment: StoredImageAttachment, + policy: ImageRequestPolicy, + hasAlpha: boolean, +): Promise { + const dimensions = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + if (dimensions.width === attachment.ref.width + && dimensions.height === attachment.ref.height + && attachment.data.byteLength <= policy.maxBytes) { + return { + data: attachment.data, + mediaType: attachment.ref.mediaType, + width: attachment.ref.width, + height: attachment.ref.height, + } + } + const encodedVersion = await encodeFirstWithinLimit( + encodingLadder(pipeline(attachment, dimensions.width, dimensions.height), hasAlpha), + policy.maxBytes, + ) + return isExhaustedEncoding(encodedVersion) ? encodedVersion.smallest : encodedVersion +} + +function cachePath(root: string, hash: string): string { + return join(root, 'request-images', hash.slice(0, 2), hash) +} + +async function readCached( + path: string, + attachment: StoredImageAttachment, + policy: ImageRequestPolicy, + expectedAlpha: boolean, + signal?: AbortSignal, +): Promise { + try { + const data = new Uint8Array(await readFile(path, { signal })) + const detected = await probeImage(data) + const maximum = requestImageDimensions(attachment.ref.width, attachment.ref.height, policy.maxPixels) + if (detected.depth !== 'uchar' || detected.space !== 'srgb' + || detected.width > maximum.width || detected.height > maximum.height + || !encodedAlphaIsCompatible(expectedAlpha, detected)) return undefined + return { data, mediaType: detected.mediaType, width: detected.width, height: detected.height, hasAlpha: detected.hasAlpha } + } catch (error: unknown) { + if ((error as NodeJS.ErrnoException | null)?.code === 'ENOENT') return undefined + signal?.throwIfAborted() + return undefined + } +} + +async function verifyRequestImage( + image: EncodedRequestImage, + expectedAlpha: boolean, +): Promise { + const detected = await detectImage(image.data) + if (detected.depth !== 'uchar' || detected.space !== 'srgb' + || detected.width !== image.width || detected.height !== image.height + || detected.mediaType !== image.mediaType || !encodedAlphaIsCompatible(expectedAlpha, detected)) { + throw new AttachmentError( + 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', + 'ATTACHMENT_WRITE_FAILED', + ) + } + return { ...image, hasAlpha: detected.hasAlpha } +} + +async function writeCached(path: string, data: Uint8Array): Promise { + await mkdir(dirname(path), { recursive: true, mode: 0o700 }) + const temporary = `${path}.${randomUUID()}.tmp` + try { + await writeFile(temporary, data, { mode: 0o600, flag: 'wx' }) + await rename(temporary, path) + } finally { + await rm(temporary, { force: true }) + } +} + +/** + * Generate or reuse one request image below the local attachment root. + * @param root - absolute versioned attachment storage root. + * @param attachment - verified normalized attachment bytes and reference. + * @param policy - exact route request-image policy. + * @param signal - optional cancellation for cache I/O and image transformation. + * @returns verified request bytes and deterministic variant identity. + */ +export async function readRequestImageFile( + root: string, + attachment: StoredImageAttachment, + policy: ImageRequestPolicy, + signal?: AbortSignal, +): Promise { + signal?.throwIfAborted() + validatePolicy(policy) + const source = await probeImage(attachment.data) + const variantId = requestImageVariantId(attachment.ref, policy) + const hash = String(variantId).slice('sha256:'.length) + const path = cachePath(root, hash) + const cached = await readCached(path, attachment, policy, source.hasAlpha, signal) + const created = cached ?? await createRequestImage(attachment, policy, source.hasAlpha) + const version = cached ?? (created.data === attachment.data + ? { ...created, hasAlpha: source.hasAlpha } + : await verifyRequestImage(created, source.hasAlpha)) + signal?.throwIfAborted() + if (cached === undefined && version.data !== attachment.data) await writeCached(path, version.data) + return { + variantId, + attachment: attachment.ref, + data: version.data, + mediaType: version.mediaType, + bytes: version.data.byteLength, + width: version.width, + height: version.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: version.hasAlpha, + } +} diff --git a/packages/attachment/attachment-local/src/store.ts b/packages/attachment/attachment-local/src/store.ts index 723df98720..e5a979aec1 100644 --- a/packages/attachment/attachment-local/src/store.ts +++ b/packages/attachment/attachment-local/src/store.ts @@ -14,7 +14,10 @@ import type { SaveImageAttachment, StoredImageAttachment, } from '@deepseek-ai/dsh-attachment' +import { normalizeImage } from './normalization.ts' +import type { NormalizationPolicy } from './normalization.ts' import { detectImage, probeImage } from './image.ts' +import type { DetectedImage } from './image.ts' const ID_PATTERN = /^sha256:([a-f0-9]{64})$/ const durableHomes = new Set() @@ -33,38 +36,91 @@ function displayName(value: string | undefined): string | undefined { return clean === '' ? undefined : clean } -function objectPath(root: string, sha256: string): string { - return join(root, 'objects', sha256.slice(0, 2), sha256) -} - function ensureReference(ref: ImageAttachmentRef): string { const match = ID_PATTERN.exec(String(ref.attachmentId)) if (match?.[1] === undefined) throw new AttachmentError('Attachment reference is invalid.', 'INVALID_ATTACHMENT_REF') return match[1] } +/** + * Derive the absolute immutable-object path for one normalized attachment. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param ref - durable normalized attachment reference. + * @returns provider-local path without reading the object. + */ +export function normalizedImagePath(root: string, ref: ImageAttachmentRef): string { + const sha256 = ensureReference(ref) + return join(root, 'objects', sha256.slice(0, 2), sha256) +} + async function inspectMetadata( data: Uint8Array, declaredMediaType: ImageAttachmentRef['mediaType'], limits: ImageAttachmentLimits, -): Promise> { +): Promise { if (data.byteLength === 0) throw new AttachmentError('Image is empty.', 'INVALID_IMAGE') const detected = await detectImage(data, { maxPixels: limits.maxImagePixels, maxDimension: limits.maxImageDimension }) if (detected.mediaType !== declaredMediaType) throw new AttachmentError('Declared image type does not match its bytes.', 'IMAGE_TYPE_MISMATCH') - return { ...detected, bytes: data.byteLength } + return detected } /** - * Run the full admission policy for one image without touching storage. + * Run the full admission policy for one image without touching storage, + * including normalization: a batch whose members all validate cannot later + * be refused by the normalized image byte cap during publication. * @param input - encoded bytes and declared metadata. - * @param limits - resolved storage policy. - * @returns completion after the encoded raster has been fully decoded. + * @param limits - resolved source admission policy. + * @param policy - resolved normalization policy. + * @returns completion after the raster has been decoded and its normalized version proven to fit. */ -export async function validateImageFile(input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { +export async function validateImageFile( + input: SaveImageAttachment, + limits: ImageAttachmentLimits, + policy: NormalizationPolicy, +): Promise { + await prepareImageFile(input, limits, policy) +} + +/** Fully prepared normalized object, verified before any batch member is persisted. */ +export interface PreparedImageFile { + /** Deterministic normalized bytes whose digest is {@link ref.attachmentId}. */ + data: Uint8Array + /** Durable reference describing {@link data}. */ + ref: ImageAttachmentRef +} + +/** + * Decode, normalize, and verify one submitted image without touching storage. + * @param input - submitted encoded bytes and declared media type. + * @param limits - source admission policy. + * @param policy - independent normalization policy. + * @returns immutable reference facts beside bytes ready for atomic publication. + */ +export async function prepareImageFile( + input: SaveImageAttachment, + limits: ImageAttachmentLimits, + policy: NormalizationPolicy, +): Promise { if (input.data.byteLength > limits.maxImageBytes) { throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') } - await inspectMetadata(input.data, input.mediaType, limits) + const detected = await inspectMetadata(input.data, input.mediaType, limits) + const normalized = await normalizeImage(input.data, detected, policy) + const sha256 = digest(normalized.data) + const name = displayName(input.name) + const downscaled = detected.width !== normalized.width || detected.height !== normalized.height + return { + data: normalized.data, + ref: { + attachmentId: AttachmentId(`sha256:${sha256}`), + mediaType: normalized.mediaType, + width: normalized.width, + height: normalized.height, + bytes: normalized.data.byteLength, + ...(name !== undefined ? { name } : {}), + ...downscaled ? { originalDimensions: { width: detected.width, height: detected.height } } : {}, + }, + } } /** @@ -127,16 +183,20 @@ async function ensureDurableHome(path: string): Promise { } /** - * Save and verify immutable image bytes below a versioned attachment root. + * Publish one already verified normalized image below a versioned attachment root. * @param root - absolute `DSH_HOME/attachments/v1` root. - * @param input - encoded bytes and declared metadata. - * @param limits - resolved storage policy. - * @returns durable content-addressed reference. + * @param prepared - deterministic normalized bytes and reference. + * @returns durable content-addressed normalized image reference. */ -export async function saveImageFile(root: string, input: SaveImageAttachment, limits: ImageAttachmentLimits): Promise { - if (input.data.byteLength > limits.maxImageBytes) throw new AttachmentError('Image exceeds the configured byte limit.', 'IMAGE_TOO_LARGE') - const metadata = await inspectMetadata(input.data, input.mediaType, limits) - const sha256 = digest(input.data) +export async function commitPreparedImageFile( + root: string, + prepared: PreparedImageFile, +): Promise { + const normalized = prepared.data + const sha256 = ensureReference(prepared.ref) + if (digest(normalized) !== sha256 || normalized.byteLength !== prepared.ref.bytes) { + throw new AttachmentError('Prepared attachment bytes do not match their reference.', 'ATTACHMENT_CORRUPT') + } const bucket = join(root, 'objects', sha256.slice(0, 2)) const staging = join(root, 'tmp') // Establish DSH_HOME itself against the filesystem root once per process. @@ -146,11 +206,11 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li await ensureDurableDirectory(bucket, boundary) await ensureDurableDirectory(staging, boundary) const temporary = join(staging, randomUUID()) - const target = objectPath(root, sha256) + const target = normalizedImagePath(root, prepared.ref) let handle try { handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, 0o600) - await handle.writeFile(input.data) + await handle.writeFile(normalized) await handle.sync() await handle.close() handle = undefined @@ -162,13 +222,18 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li const existing = new Uint8Array(await readFile(target)) if (digest(existing) !== sha256) throw new AttachmentError('Stored attachment failed integrity verification.', 'ATTACHMENT_CORRUPT') } + // Windows shares the read-only attribute across hard links and refuses to + // unlink either name once it is set, so discard the staging name first. + await unlink(temporary) + // The target remains the sole link for a new object; this also restores + // read-only mode when the deduplication path observes an existing object. + await chmod(target, 0o400) // Persist the target entry and close a concurrent bucket-creation window // before the reference can reach a session checkpoint. The dedup path // repeats both syncs because it may observe another writer's link before // that writer reaches its own durability boundary. await syncDirectory(bucket) await syncDirectory(join(root, 'objects')) - await unlink(temporary) } catch (error) { /* v8 ignore next -- A descriptor can remain open only when the underlying write/sync/close operation fails. */ if (handle !== undefined) await handle.close().catch( @@ -185,12 +250,24 @@ export async function saveImageFile(root: string, input: SaveImageAttachment, li if (error instanceof AttachmentError) throw error throw new AttachmentError('Unable to persist image attachment.', 'ATTACHMENT_WRITE_FAILED', { cause: error }) } - const name = displayName(input.name) - return { - attachmentId: AttachmentId(`sha256:${sha256}`), - ...metadata, - ...(name !== undefined ? { name } : {}), - } + return prepared.ref +} + +/** + * Decode and normalize one image once, then publish the prepared object. + * @param root - absolute `DSH_HOME/attachments/v1` root. + * @param input - submitted encoded bytes and declared media type. + * @param limits - resolved source admission policy. + * @param policy - resolved normalization policy. + * @returns durable content-addressed normalized image reference. + */ +export async function saveImageFile( + root: string, + input: SaveImageAttachment, + limits: ImageAttachmentLimits, + policy: NormalizationPolicy, +): Promise { + return commitPreparedImageFile(root, await prepareImageFile(input, limits, policy)) } /** @@ -210,7 +287,7 @@ export async function readImageFile( const sha256 = ensureReference(ref) let data: Uint8Array try { - data = new Uint8Array(await readFile(objectPath(root, sha256), { signal })) + data = new Uint8Array(await readFile(normalizedImagePath(root, ref), { signal })) } catch (error) { signal?.throwIfAborted() if (error instanceof Error && 'code' in error && error.code === 'ENOENT') throw new AttachmentError('Attachment object is missing.', 'ATTACHMENT_NOT_FOUND') diff --git a/packages/attachment/attachment-local/tests/encoding.spec.ts b/packages/attachment/attachment-local/tests/encoding.spec.ts new file mode 100644 index 0000000000..8cd7a60540 --- /dev/null +++ b/packages/attachment/attachment-local/tests/encoding.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it, vi } from 'vitest' +import { CompressionLimiter } from '../src/compression-limiter.ts' +import { encodeFirstWithinLimit, isExhaustedEncoding } from '../src/encoding.ts' + +describe('lazy image encoding', () => { + it('does not execute fallback qualities after the first fitting candidate', async () => { + const first = vi.fn(() => Promise.resolve({ data: new Uint8Array(8), quality: 85 })) + const fallback = vi.fn(() => Promise.resolve({ data: new Uint8Array(4), quality: 80 })) + + await expect(encodeFirstWithinLimit([first, fallback], 8)).resolves.toMatchObject({ quality: 85 }) + expect(first).toHaveBeenCalledTimes(1) + expect(fallback).not.toHaveBeenCalled() + }) + + it('executes later candidates only after earlier candidates exceed the cap', async () => { + const first = vi.fn(() => Promise.resolve({ data: new Uint8Array(12), quality: 85 })) + const second = vi.fn(() => Promise.resolve({ data: new Uint8Array(7), quality: 80 })) + const third = vi.fn(() => Promise.resolve({ data: new Uint8Array(5), quality: 75 })) + + await expect(encodeFirstWithinLimit([first, second, third], 8)).resolves.toMatchObject({ quality: 80 }) + expect(first).toHaveBeenCalledTimes(1) + expect(second).toHaveBeenCalledTimes(1) + expect(third).not.toHaveBeenCalled() + }) + + it('rejects an empty candidate list and reports the smallest exhausted candidate', async () => { + await expect(encodeFirstWithinLimit([], 8)).rejects.toThrow('requires at least one candidate') + const result = await encodeFirstWithinLimit([ + () => Promise.resolve({ data: new Uint8Array(12), quality: 85 }), + () => Promise.resolve({ data: new Uint8Array(9), quality: 80 }), + () => Promise.resolve({ data: new Uint8Array(10), quality: 75 }), + ], 8) + + expect(isExhaustedEncoding(result)).toBe(true) + expect(result).toMatchObject({ smallest: { quality: 80 } }) + expect(isExhaustedEncoding({ data: new Uint8Array(1) })).toBe(false) + }) +}) + +describe('CompressionLimiter', () => { + it('starts at most the configured number of tasks and preserves queued progress', async () => { + const limiter = new CompressionLimiter(2) + const gates = Array.from({ length: 4 }, () => Promise.withResolvers()) + let active = 0 + let maximum = 0 + const started: number[] = [] + const tasks = gates.map((gate, index) => limiter.run(async () => { + active += 1 + maximum = Math.max(maximum, active) + started.push(index) + await gate.promise + active -= 1 + return index + })) + + await Promise.resolve() + expect(started).toEqual([0, 1]) + gates[0]!.resolve(undefined) + await tasks[0] + await Promise.resolve() + expect(started).toEqual([0, 1, 2]) + gates[1]!.resolve(undefined) + gates[2]!.resolve(undefined) + await Promise.all([tasks[1], tasks[2]]) + await Promise.resolve() + expect(started).toEqual([0, 1, 2, 3]) + gates[3]!.resolve(undefined) + + await expect(Promise.all(tasks)).resolves.toEqual([0, 1, 2, 3]) + expect(maximum).toBe(2) + }) + + it('releases a slot when a task throws before returning a promise', async () => { + const limiter = new CompressionLimiter(1) + const failed = limiter.run(() => { + throw new Error('synchronous setup failure') + }) + const next = limiter.run(() => Promise.resolve('next')) + + await expect(failed).rejects.toThrow('synchronous setup failure') + await expect(next).resolves.toBe('next') + }) + + it('normalizes a non-Error rejection and releases its slot', async () => { + const limiter = new CompressionLimiter(1) + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- Native bindings can reject non-Error values. + const failed = limiter.run(() => Promise.reject('native failure')) + const next = limiter.run(() => Promise.resolve('next')) + + await expect(failed).rejects.toMatchObject({ + message: 'Image compression task rejected with a non-Error value.', + cause: 'native failure', + }) + await expect(next).resolves.toBe('next') + }) +}) diff --git a/packages/attachment/attachment-local/tests/image.spec.ts b/packages/attachment/attachment-local/tests/image.spec.ts index 6b1cea6bfb..848aa3ea28 100644 --- a/packages/attachment/attachment-local/tests/image.spec.ts +++ b/packages/attachment/attachment-local/tests/image.spec.ts @@ -18,7 +18,7 @@ describe('raster decoding', () => { ['gif', 'image/gif'], ] as const) { await expect(detectImage(await raster(format))) - .resolves.toEqual({ mediaType, width: 3, height: 2 }) + .resolves.toMatchObject({ mediaType, width: 3, height: 2, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) } }) @@ -31,7 +31,7 @@ describe('raster decoding', () => { await expect(detectImage(await raster('png'), { maxDimension: 2 })) .rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) await expect(detectImage(await raster('png'), { maxDimension: 3 })) - .resolves.toEqual({ mediaType: 'image/png', width: 3, height: 2 }) + .resolves.toMatchObject({ mediaType: 'image/png', width: 3, height: 2, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) }) it('rejects malformed bytes and truncated payloads with readable headers', async () => { @@ -47,6 +47,39 @@ describe('raster decoding', () => { await expect(detectImage(truncated)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) }) + it('reports animation from a multi-frame container and perceived axes from EXIF orientation', async () => { + const header = Buffer.from('47494638396101000100800000000000ffffff', 'hex') + const frame = Buffer.from('21f90401000000002c0000000001000100000202440100', 'hex') + const twoFrameGif = Uint8Array.from(Buffer.concat([header, frame, frame, Buffer.from('3b', 'hex')])) + await expect(detectImage(twoFrameGif)).resolves.toMatchObject({ mediaType: 'image/gif', animated: true }) + + const oriented = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).jpeg().withMetadata({ orientation: 6 }).toBuffer()) + await expect(detectImage(oriented)).resolves.toMatchObject({ + mediaType: 'image/jpeg', width: 2, height: 4, animated: false, carriesMetadata: true, + }) + + const flipped = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).jpeg().withMetadata({ orientation: 3 }).toBuffer()) + await expect(detectImage(flipped)).resolves.toMatchObject({ + mediaType: 'image/jpeg', width: 4, height: 2, animated: false, carriesMetadata: true, + }) + }) + + it('reports color profiles and encoder metadata as metadata', async () => { + const profiled = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().withIccProfile('p3').toBuffer()) + await expect(detectImage(profiled)).resolves.toMatchObject({ carriesMetadata: true }) + + const commented = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().withMetadata().toBuffer()) + await expect(detectImage(commented)).resolves.toMatchObject({ carriesMetadata: true }) + }) + it('probes malformed bytes and unsupported formats into the same stable error', async () => { await expect(probeImage(Uint8Array.of(1, 2, 3))) .rejects.toMatchObject({ code: 'INVALID_IMAGE' }) diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 92bbe3c0aa..4d9d9f350b 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,10 +1,16 @@ import { Context } from '@deepseek-ai/cordis' +import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { existsSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' +import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' +import sharp from 'sharp' import LocalAttachmentStore, { + DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, + DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, + DEFAULT_IMAGE_COMPRESSION_CONCURRENCY, DEFAULT_MAX_IMAGE_BYTES, DEFAULT_MAX_IMAGE_DIMENSION, DEFAULT_MAX_IMAGE_PIXELS, @@ -15,7 +21,11 @@ import LocalAttachmentStore, { describe('local attachment service', () => { it('resolves every omitted admission limit explicitly', () => { const service = new LocalAttachmentStore(new Context(), {}) - expect(DEFAULT_MAX_IMAGE_BYTES).toBe(3.5 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_BYTES).toBe(20 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGES_PER_MESSAGE).toBe(20) + expect(DEFAULT_MAX_MESSAGE_IMAGE_BYTES).toBe(200 * 1024 * 1024) + expect(DEFAULT_MAX_IMAGE_PIXELS).toBe(64_000_000) + expect(DEFAULT_MAX_IMAGE_DIMENSION).toBe(8192) expect(service.imageLimits).toEqual({ maxImageBytes: DEFAULT_MAX_IMAGE_BYTES, maxImagesPerMessage: DEFAULT_MAX_IMAGES_PER_MESSAGE, @@ -24,6 +34,35 @@ describe('local attachment service', () => { maxImageDimension: DEFAULT_MAX_IMAGE_DIMENSION, mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], }) + expect(service.normalizationPolicy).toEqual({ + maxPixels: DEFAULT_NORMALIZED_IMAGE_MAX_PIXELS, + maxDimension: DEFAULT_NORMALIZED_IMAGE_MAX_DIMENSION, + maxBytes: DEFAULT_NORMALIZED_IMAGE_MAX_BYTES, + }) + expect(service.imageCompressionConcurrency).toBe(DEFAULT_IMAGE_COMPRESSION_CONCURRENCY) + const ref = { + attachmentId: AttachmentId(`sha256:${'a'.repeat(64)}`), + mediaType: 'image/png' as const, + bytes: 1, + width: 1, + height: 1, + } + expect(service.imageHostPath(ref)).toBe(join( + service.root, + 'objects', + 'aa', + 'a'.repeat(64), + )) + expect(() => service.imageHostPath({ ...ref, attachmentId: AttachmentId('invalid') })) + .toThrow(expect.objectContaining({ code: 'INVALID_ATTACHMENT_REF' })) + }) + + it('resolves and validates the instance image-compression concurrency', () => { + expect(new LocalAttachmentStore(new Context(), { imageCompressionConcurrency: 1 }).imageCompressionConcurrency).toBe(1) + for (const imageCompressionConcurrency of [0, 1.5, 9]) { + expect(() => new LocalAttachmentStore(new Context(), { imageCompressionConcurrency })) + .toThrow(/imageCompressionConcurrency must be an integer from 1 through 8/) + } }) it('saves and reads through the service boundary', async () => { @@ -31,11 +70,84 @@ describe('local attachment service', () => { try { const service = new LocalAttachmentStore(new Context(), { dshHome }) const data = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) const ref = await service.saveImage({ data, mediaType: 'image/png' }) await expect(service.readImage(ref)).resolves.toEqual({ ref, data }) + const hostPath = service.imageHostPath(ref) + expect(hostPath).toBe(join( + dshHome, + 'attachments', + 'v1', + 'objects', + String(ref.attachmentId).slice('sha256:'.length, 'sha256:'.length + 2), + String(ref.attachmentId).slice('sha256:'.length), + )) + await expect(readFile(hostPath)).resolves.toEqual(Buffer.from(data)) + const request = await service.readImageRequest(ref, { maxPixels: 1, maxBytes: 1024 }) + expect(request).not.toHaveProperty('access') + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + + it('commits a fully prepared image batch in input order', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-success-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const first = new Uint8Array(await sharp({ + create: { width: 2, height: 1, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().toBuffer()) + const second = new Uint8Array(await sharp({ + create: { width: 1, height: 2, channels: 3, background: { r: 4, g: 5, b: 6 } }, + }).png().toBuffer()) + + const refs = await service.saveImages([ + { data: first, mediaType: 'image/png', name: 'first.png' }, + { data: second, mediaType: 'image/png', name: 'second.png' }, + ]) + + expect(refs.map(ref => ref.name)).toEqual(['first.png', 'second.png']) + await expect(Promise.all(refs.map(ref => service.readImage(ref)))) + .resolves.toHaveLength(2) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + + it.each([3, 4] as const)('admits a 16-bit %s-channel PNG as an 8-bit normalized object', async (channels) => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-16-bit-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const source = new Uint8Array(await sharp({ + create: { width: 7, height: 5, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, + }).toColourspace('rgb16').png().toBuffer()) + + const saved = await service.saveImage({ data: source, mediaType: 'image/png' }) + const stored = await service.readImage(saved) + const metadata = await sharp(stored.data).metadata() + + expect(stored.data).not.toEqual(source) + expect(metadata).toMatchObject({ depth: 'uchar', space: 'srgb', hasAlpha: channels === 4 }) + } finally { + await rm(dshHome, { recursive: true, force: true }) + } + }) + + it('prepares every batch member before any write', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-attachment-batch-')) + try { + const service = new LocalAttachmentStore(new Context(), { dshHome }) + const valid = Uint8Array.from(Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', + 'base64', + )) + await expect(service.saveImages([ + { data: valid, mediaType: 'image/png' }, + { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' }, + ])).rejects.toThrow(/Unsupported or malformed image data/) + expect(existsSync(service.root)).toBe(false) } finally { await rm(dshHome, { recursive: true, force: true }) } @@ -48,7 +160,7 @@ describe('local attachment service', () => { await expect(service.validateImage({ data: Uint8Array.of(1, 2, 3), mediaType: 'image/png' })) .rejects.toThrow(/Unsupported or malformed image data/) const valid = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) const limited = new LocalAttachmentStore(new Context(), { dshHome, maxImageBytes: 1 }) diff --git a/packages/attachment/attachment-local/tests/normalization-verification.spec.ts b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts new file mode 100644 index 0000000000..4a9faf6d7d --- /dev/null +++ b/packages/attachment/attachment-local/tests/normalization-verification.spec.ts @@ -0,0 +1,38 @@ +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const control = vi.hoisted(() => ({ mismatch: false })) + +vi.mock('../src/image.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async detectImage(data: Uint8Array): Promise>> { + const detected = await actual.detectImage(data) + return control.mismatch ? { ...detected, width: detected.width + 1 } : detected + }, + } +}) + +import { normalizeImage } from '../src/normalization.ts' +import { detectImage } from '../src/image.ts' + +afterEach(() => { + control.mismatch = false +}) + +describe('normalization verification', () => { + it('rejects a normalized output whose decoded facts disagree with the encoder result', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 10, height: 6, channels: 3, background: { r: 12, g: 200, b: 64 } }, + }).png().toBuffer()) + const detected = await detectImage(data) + control.mismatch = true + + await expect(normalizeImage(data, detected, { maxPixels: 2048 * 2048, maxDimension: 5, maxBytes: 4 * 1024 * 1024 })) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'Image normalization did not produce a single-frame 8-bit sRGB image with matching metadata.', + }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/normalization.spec.ts b/packages/attachment/attachment-local/tests/normalization.spec.ts new file mode 100644 index 0000000000..d60989075d --- /dev/null +++ b/packages/attachment/attachment-local/tests/normalization.spec.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'vitest' +import sharp from 'sharp' +import { canPassThroughNormalization, normalizeImage } from '../src/normalization.ts' +import type { NormalizationPolicy } from '../src/normalization.ts' +import { detectImage } from '../src/image.ts' + +const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 4 * 1024 * 1024 } + +/** Deterministic pseudo-random RGB noise; PNG cannot compress it below raw size. */ +function noisePixels(width: number, height: number): Uint8Array { + const pixels = new Uint8Array(width * height * 3) + let state = 0x2545f491 + for (let index = 0; index < pixels.length; index += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + pixels[index] = state & 0xff + } + return pixels +} + +async function noiseImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif'): Promise { + const image = sharp(noisePixels(width, height), { raw: { width, height, channels: 3 } }) + return new Uint8Array(await image.toFormat(format).toBuffer()) +} + +async function flatImage(width: number, height: number, format: 'png' | 'jpeg' | 'webp' | 'gif', alpha = false): Promise { + const image = sharp({ + create: { width, height, channels: alpha ? 4 : 3, background: { r: 12, g: 200, b: 64, alpha: alpha ? 0.5 : 1 } }, + }) + return new Uint8Array(await image.toFormat(format, format === 'webp' && alpha ? { lossless: true } : {}).toBuffer()) +} + +describe('canPassThroughNormalization', () => { + it('accepts an in-budget clean PNG/JPEG/WebP and refuses GIF, animation, metadata, oversized edges, and oversized bytes', () => { + const clean = { animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false } + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 8192, height: 4, ...clean }, 100, POLICY)).toBe(true) + expect(canPassThroughNormalization({ mediaType: 'image/gif', width: 4, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, animated: true, carriesMetadata: false, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 4, height: 4, animated: false, carriesMetadata: true, depth: 'uchar', space: 'srgb', hasAlpha: false }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, depth: 'ushort' }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/png', width: 4, height: 4, ...clean, space: 'rgb16' }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 2049, height: 2048, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/jpeg', width: 8193, height: 4, ...clean }, 100, POLICY)).toBe(false) + expect(canPassThroughNormalization({ mediaType: 'image/webp', width: 4, height: 4, ...clean }, POLICY.maxBytes + 1, POLICY)).toBe(false) + }) +}) + +describe('normalizeImage', () => { + it('passes an already-normalized source through byte-identically', async () => { + const data = await flatImage(6, 4, 'webp') + const detected = await detectImage(data) + + const normalized = await normalizeImage(data, detected, POLICY) + + expect(normalized.data).toBe(data) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 6, height: 4 }) + }) + + it.each([3, 4] as const)('converts a 16-bit %s-channel PNG to 8-bit sRGB without passthrough', async (channels) => { + const data = new Uint8Array(await sharp({ + create: { width: 7, height: 5, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, + }).toColourspace('rgb16').png().toBuffer()) + const detected = await detectImage(data) + expect(detected).toMatchObject({ depth: 'ushort', space: 'rgb16', hasAlpha: channels === 4 }) + + const normalized = await normalizeImage(data, detected, POLICY) + + expect(normalized.data).not.toBe(data) + expect(normalized.data).not.toEqual(data) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ + depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, width: 7, height: 5, + }) + }) + + it('downscales an oversized opaque PNG to the long-edge target as JPEG', async () => { + const data = await flatImage(10, 6, 'png') + const detected = await detectImage(data) + + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 3, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + const again = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes }) + expect(again.data).toEqual(normalized.data) + }) + + it('re-encodes the normalized output of a resize into itself (idempotence)', async () => { + const data = await flatImage(10, 6, 'png') + const budget = { maxPixels: POLICY.maxPixels, maxDimension: 5, maxBytes: POLICY.maxBytes } + const first = await normalizeImage(data, await detectImage(data), budget) + + const second = await normalizeImage(first.data, await detectImage(first.data), budget) + + expect(second.data).toBe(first.data) + }) + + it('always re-encodes GIF as a single still frame', async () => { + const data = await flatImage(6, 4, 'gif') + const detected = await detectImage(data) + + const normalized = await normalizeImage(data, detected, POLICY) + + // gifload always decodes to RGBA, so a GIF re-encodes on the WebP ladder. + expect(detected.hasAlpha).toBe(true) + expect(normalized.mediaType).toBe('image/webp') + await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 6, height: 4, animated: false, carriesMetadata: false, depth: 'uchar', space: 'srgb' }) + }) + + it('keeps a transparent source on the WebP ladder', async () => { + const data = await flatImage(9, 5, 'webp', true) + const detected = await detectImage(data) + + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 4, maxBytes: POLICY.maxBytes }) + + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 4, height: 2 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true }) + }) + + it('accepts WebP output that omits an all-opaque source alpha plane', async () => { + const width = 64 + const height = 32 + const rgb = noisePixels(width, height) + const rgba = new Uint8Array(width * height * 4) + for (let pixel = 0; pixel < width * height; pixel += 1) { + rgba[pixel * 4] = rgb[pixel * 3] ?? 0 + rgba[pixel * 4 + 1] = rgb[pixel * 3 + 1] ?? 0 + rgba[pixel * 4 + 2] = rgb[pixel * 3 + 2] ?? 0 + rgba[pixel * 4 + 3] = 255 + } + const data = new Uint8Array(await sharp(rgba, { + raw: { width, height, channels: 4 }, + }).png().toBuffer()) + await expect(detectImage(data)).resolves.toMatchObject({ hasAlpha: true }) + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, + maxDimension: 32, + maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: 32, height: 16 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: false }) + }) + + it('keeps the smallest transparent ladder output above an unreachable byte target without shrinking', async () => { + const side = 128 + const pixels = new Uint8Array(side * side * 4) + const noise = noisePixels(side, side) + for (let pixel = 0; pixel < side * side; pixel += 1) { + const target = pixel * 4 + const source = pixel * 3 + pixels[target] = noise[source] ?? 0 + pixels[target + 1] = noise[source + 1] ?? 0 + pixels[target + 2] = noise[source + 2] ?? 0 + pixels[target + 3] = pixel & 0xff + } + const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 4 } }).png().toBuffer()) + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: side, maxBytes: 1_024, + }) + + expect(normalized.data.byteLength).toBeGreaterThan(1_024) + expect(normalized).toMatchObject({ mediaType: 'image/webp', width: side, height: side }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) + }) + + it('re-encodes an oversized photographic JPEG as JPEG', async () => { + const data = await noiseImage(64, 32, 'jpeg') + const detected = await detectImage(data) + + const normalized = await normalizeImage(data, detected, { maxPixels: POLICY.maxPixels, maxDimension: 32, maxBytes: POLICY.maxBytes }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 32, height: 16 }) + }) + + it('re-encodes an opaque gradient PNG as JPEG within the byte target', async () => { + const side = 256 + const pixels = new Uint8Array(side * side * 3) + for (let y = 0; y < side; y += 1) { + for (let x = 0; x < side; x += 1) { + const index = (y * side + x) * 3 + pixels[index] = x & 0xff + pixels[index + 1] = y & 0xff + pixels[index + 2] = (x + y) >> 1 & 0xff + } + } + const data = new Uint8Array(await sharp(pixels, { raw: { width: side, height: side, channels: 3 } }).png().toBuffer()) + const detected = await detectImage(data) + const budget = { maxPixels: POLICY.maxPixels, maxDimension: 128, maxBytes: POLICY.maxBytes } + + const normalized = await normalizeImage(data, detected, budget) + + expect(normalized.mediaType).toBe('image/jpeg') + expect(normalized).toMatchObject({ width: 128, height: 128 }) + expect(normalized.data.byteLength).toBeLessThanOrEqual(budget.maxBytes) + }) + + it('keeps the smallest opaque ladder output above an unreachable byte target', async () => { + const data = await noiseImage(64, 64, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: POLICY.maxPixels, maxDimension: 2048, maxBytes: 512, + }) + + expect(normalized.data.byteLength).toBeGreaterThan(512) + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 64, height: 64 }) + }) + + it('re-encodes an in-budget oriented JPEG, baking rotation and stripping metadata', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).jpeg().withMetadata({ orientation: 6 }).toBuffer()) + const detected = await detectImage(data) + // Orientation 6 rotates 90°: the perceived source is 2x4. + expect(detected).toMatchObject({ width: 2, height: 4, carriesMetadata: true }) + + const normalized = await normalizeImage(data, detected, POLICY) + + expect(normalized.data).not.toBe(data) + expect(normalized).toMatchObject({ width: 2, height: 4 }) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ width: 2, height: 4, carriesMetadata: false }) + }) + + it('re-encodes an in-budget image with an ICC profile and strips the profile', async () => { + const data = new Uint8Array(await sharp({ + create: { width: 4, height: 2, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).png().withIccProfile('p3').toBuffer()) + const detected = await detectImage(data) + expect(detected.carriesMetadata).toBe(true) + + const normalized = await normalizeImage(data, detected, POLICY) + + expect(normalized.data).not.toBe(data) + await expect(detectImage(normalized.data)).resolves.toMatchObject({ carriesMetadata: false }) + }) + + it('maps an encoder fault on undecodable bytes to a storage failure', async () => { + const detected = { + mediaType: 'image/png', width: 5000, height: 5000, animated: false, carriesMetadata: false, + depth: 'ushort', space: 'rgb16', hasAlpha: true, + } as const + await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'The 16-bit PNG could not be converted to the normalized 8-bit sRGB form.', + }) + }) + + it.each([ + ['float PNG', { mediaType: 'image/png', depth: 'float' }], + ['uchar JPEG', { mediaType: 'image/jpeg', depth: 'uchar' }], + ] as const)('describes a failed %s conversion without exposing the encoder error', async (source, fields) => { + const detected = { + ...fields, + width: 5000, + height: 5000, + animated: false, + carriesMetadata: false, + space: 'srgb', + hasAlpha: false, + } as const + + await expect(normalizeImage(Uint8Array.of(1, 2, 3), detected, POLICY)) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: `The ${source} could not be converted to the normalized 8-bit sRGB form.`, + }) + }) + + it('downscales by total pixels so an extreme aspect ratio keeps its short edge', async () => { + const data = await flatImage(10, 40, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: 100, maxDimension: 8192, maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 5, height: 20 }) + }) + + it('caps the long edge after the total-pixel budget', async () => { + const data = await flatImage(4, 64, 'png') + + const normalized = await normalizeImage(data, await detectImage(data), { + maxPixels: 10_000, maxDimension: 16, maxBytes: POLICY.maxBytes, + }) + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 1, height: 16 }) + }) + + it('keeps an antialiased text screenshot readable on the JPEG ladder', async () => { + const source = new Uint8Array(await sharp(Buffer.from(` + + + Readable text + + `)).removeAlpha().png().toBuffer()) + + const normalized = await normalizeImage(source, await detectImage(source), { + maxPixels: POLICY.maxPixels, + maxDimension: 512, + maxBytes: POLICY.maxBytes, + }) + const stats = await sharp(normalized.data).greyscale().stats() + + expect(normalized).toMatchObject({ mediaType: 'image/jpeg', width: 512, height: 256 }) + expect(stats.channels[0]?.min).toBeLessThan(80) + expect(stats.channels[0]?.max).toBeGreaterThan(240) + }) +}) diff --git a/packages/attachment/attachment-local/tests/request-image-verification.spec.ts b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts new file mode 100644 index 0000000000..32bc005c94 --- /dev/null +++ b/packages/attachment/attachment-local/tests/request-image-verification.spec.ts @@ -0,0 +1,47 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' + +const control = vi.hoisted(() => ({ mismatch: false })) + +vi.mock('../src/image.ts', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + async detectImage(data: Uint8Array): Promise>> { + const detected = await actual.detectImage(data) + return control.mismatch ? { ...detected, width: detected.width + 1 } : detected + }, + } +}) + +import LocalAttachmentStore from '../src/index.ts' + +const homes: string[] = [] + +afterEach(async () => { + control.mismatch = false + await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) +}) + +describe('request image verification', () => { + it('rejects an encoded request whose decoded facts disagree with the encoder result', async () => { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-verification-')) + homes.push(dshHome) + const attachments = new LocalAttachmentStore(new Context(), { dshHome }) + const source = new Uint8Array(await sharp({ + create: { width: 64, height: 32, channels: 3, background: { r: 12, g: 34, b: 56 } }, + }).png().toBuffer()) + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) + control.mismatch = true + + await expect(attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 })) + .rejects.toMatchObject({ + code: 'ATTACHMENT_WRITE_FAILED', + message: 'Encoded model-request image does not match its verified 8-bit sRGB metadata.', + }) + }) +}) diff --git a/packages/attachment/attachment-local/tests/request-image.spec.ts b/packages/attachment/attachment-local/tests/request-image.spec.ts new file mode 100644 index 0000000000..821480d028 --- /dev/null +++ b/packages/attachment/attachment-local/tests/request-image.spec.ts @@ -0,0 +1,324 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import sharp from 'sharp' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { CompressionLimiter } from '../src/compression-limiter.ts' +import LocalAttachmentStore from '../src/index.ts' + +const homes: string[] = [] + +async function store(): Promise { + const dshHome = await mkdtemp(join(tmpdir(), 'dsh-request-image-')) + homes.push(dshHome) + return new LocalAttachmentStore(new Context(), { dshHome }) +} + +async function image(width: number, height: number): Promise { + return new Uint8Array(await sharp({ + create: { width, height, channels: 3, background: { r: 12, g: 34, b: 56 } }, + }).png().toBuffer()) +} + +async function complexOpaqueAlphaImage(width: number, height: number): Promise { + const pixels = new Uint8Array(width * height * 4) + let state = 0x2545f491 + for (let offset = 0; offset < pixels.length; offset += 4) { + for (let channel = 0; channel < 3; channel += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + pixels[offset + channel] = state & 0xff + } + pixels[offset + 3] = 255 + } + return new Uint8Array(await sharp(pixels, { + raw: { width, height, channels: 4 }, + }).png().toBuffer()) +} + +afterEach(async () => { + await Promise.all(homes.splice(0).map(home => rm(home, { recursive: true, force: true }))) +}) + +describe('local request-image cache', () => { + it('passes through an in-budget attachment and composes ordered request reads', async () => { + const attachments = await store() + const first = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) + const second = await attachments.saveImage({ data: await image(4, 8), mediaType: 'image/png' }) + const firstStored = await attachments.readImage(first) + const policy = { maxPixels: 1_000, maxBytes: 1024 * 1024 } + + const request = await attachments.readImageRequest(first, policy) + const batch = await Promise.all([first, second].map( + attachment => attachments.readImageRequest(attachment, policy), + )) + + expect(request.data).toEqual(firstStored.data) + expect(batch.map(value => value.attachment.attachmentId)).toEqual([first.attachmentId, second.attachmentId]) + }) + + it('rejects invalid request policies', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ data: await image(8, 4), mediaType: 'image/png' }) + + await expect(attachments.readImageRequest(attachment, { maxPixels: 0, maxBytes: 100 })) + .rejects.toThrow('Image request maxPixels must be a positive integer') + await expect(attachments.readImageRequest(attachment, { maxPixels: 100, maxBytes: 0 })) + .rejects.toThrow('Image request maxBytes must be a positive integer') + }) + + it('keeps the smallest ladder output when the encoded-byte target is unreachable', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ data: await image(1, 1), mediaType: 'image/png' }) + + const request = await attachments.readImageRequest(attachment, { maxPixels: 1, maxBytes: 1 }) + + expect(request.mediaType).toBe('image/jpeg') + expect(request.bytes).toBeGreaterThan(1) + expect(request).toMatchObject({ width: 1, height: 1 }) + }) + + it('regenerates invalid, oversized, incompatible, or mismatched cached variants', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ data: await image(64, 32), mediaType: 'image/png' }) + const policy = { maxPixels: 16 * 16, maxBytes: 4_096 } + const initial = await attachments.readImageRequest(attachment, policy) + const hash = String(initial.variantId).slice('sha256:'.length) + const path = join(attachments.root, 'request-images', hash.slice(0, 2), hash) + const noisyPixels = new Uint8Array(64 * 64 * 3) + let state = 0x2545f491 + for (let index = 0; index < noisyPixels.length; index += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + noisyPixels[index] = state & 0xff + } + const oversized = new Uint8Array(await sharp(noisyPixels, { + raw: { width: 64, height: 64, channels: 3 }, + }).png().toBuffer()) + const depth16 = new Uint8Array(await sharp({ + create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).toColourspace('rgb16').png().toBuffer()) + const cmyk = new Uint8Array(await sharp({ + create: { width: 16, height: 8, channels: 3, background: { r: 1, g: 2, b: 3 } }, + }).toColourspace('cmyk').jpeg().toBuffer()) + const tooWide = await image(23, 11) + const unexpectedAlpha = new Uint8Array(await sharp({ + create: { width: 16, height: 8, channels: 4, background: { r: 1, g: 2, b: 3, alpha: 0.5 } }, + }).png().toBuffer()) + + for (const invalid of [ + oversized, + depth16, + cmyk, + tooWide, + unexpectedAlpha, + Uint8Array.of(1, 2, 3), + ]) { + await writeFile(path, invalid) + const regenerated = await attachments.readImageRequest(attachment, policy) + expect(regenerated.data).toEqual(initial.data) + } + }) + + it('derives stable square and wide previews and separates route budgets in the cache key', async () => { + const attachments = await store() + const square = await attachments.saveImage({ + data: await image(2048, 2048), mediaType: 'image/png', name: 'square.png', + }) + const wide = await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'wide.png', + }) + + const squareRequest = await attachments.readImageRequest(square, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const wideRequest = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const repeated = await attachments.readImageRequest(wide, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + const low = await attachments.readImageRequest(wide, { maxPixels: 512 * 512, maxBytes: 1024 * 1024 }) + + expect(squareRequest).toMatchObject({ width: 800, height: 800 }) + expect(wideRequest).toMatchObject({ width: 1130, height: 565 }) + expect(repeated.variantId).toBe(wideRequest.variantId) + expect(repeated.data).toEqual(wideRequest.data) + expect(Buffer.from(repeated.data).toString('base64')).toBe(Buffer.from(wideRequest.data).toString('base64')) + expect(low.variantId).not.toBe(wideRequest.variantId) + expect(low.width * low.height).toBeLessThanOrEqual(512 * 512 + low.width) + }) + + it('routes opaque pixels to JPEG and preserves alpha on the WebP ladder', async () => { + const attachments = await store() + const side = 256 + const photoPixels = new Uint8Array(side * side * 3) + const alphaPixels = new Uint8Array(side * side * 4) + let state = 0x2545f491 + for (let pixel = 0; pixel < side * side; pixel += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + const photo = pixel * 3 + const alpha = pixel * 4 + photoPixels[photo] = state & 0xff + photoPixels[photo + 1] = state >> 8 & 0xff + photoPixels[photo + 2] = state >> 16 & 0xff + alphaPixels[alpha] = photoPixels[photo] ?? 0 + alphaPixels[alpha + 1] = photoPixels[photo + 1] ?? 0 + alphaPixels[alpha + 2] = photoPixels[photo + 2] ?? 0 + alphaPixels[alpha + 3] = pixel & 0xff + } + const photoSource = new Uint8Array(await sharp(photoPixels, { + raw: { width: side, height: side, channels: 3 }, + }).png().toBuffer()) + const alphaSource = new Uint8Array(await sharp(alphaPixels, { + raw: { width: side, height: side, channels: 4 }, + }).png().toBuffer()) + const photo = await attachments.saveImage({ data: photoSource, mediaType: 'image/png' }) + const alpha = await attachments.saveImage({ data: alphaSource, mediaType: 'image/png' }) + + const photoRequest = await attachments.readImageRequest(photo, { maxPixels: 128 * 128, maxBytes: 1024 * 1024 }) + const alphaRequest = await attachments.readImageRequest(alpha, { maxPixels: 128 * 128, maxBytes: 4_096 }) + + expect(photoRequest.mediaType).toBe('image/jpeg') + expect(alphaRequest.mediaType).toBe('image/webp') + expect(alphaRequest.bytes).toBeGreaterThan(4_096) + expect(alphaRequest).toMatchObject({ width: 128, height: 128 }) + await expect(sharp(alphaRequest.data).metadata()).resolves.toMatchObject({ hasAlpha: true, depth: 'uchar', space: 'srgb' }) + }) + + it.each([3, 4] as const)('projects a 16-bit %s-channel PNG as a bounded 8-bit request image', async (channels) => { + const attachments = await store() + const source = new Uint8Array(await sharp({ + create: { width: 64, height: 32, channels, background: { r: 12, g: 34, b: 56, alpha: 0.5 } }, + }).toColourspace('rgb16').png().toBuffer()) + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) + + const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + + expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) + expect(request.width * request.height).toBeLessThanOrEqual(16 * 16) + await expect(sharp(request.data).metadata()).resolves.toMatchObject({ + depth: 'uchar', space: 'srgb', hasAlpha: channels === 4, + }) + }) + + it('accepts a resized WebP request version that omits an all-opaque alpha plane', async () => { + const attachments = await store() + const source = await complexOpaqueAlphaImage(64, 32) + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) + + const request = await attachments.readImageRequest(attachment, { maxPixels: 16 * 16, maxBytes: 1024 * 1024 }) + + expect(request.mediaType).toBe('image/webp') + await expect(sharp(request.data).metadata()).resolves.toMatchObject({ hasAlpha: false }) + }) + + it('keeps a complex 640,000-pixel request version below 1 MiB', async () => { + const attachments = await store() + const side = 1024 + const pixels = new Uint8Array(side * side * 3) + let state = 0x6d2b79f5 + for (let index = 0; index < pixels.length; index += 1) { + state ^= state << 13 + state ^= state >>> 17 + state ^= state << 5 + pixels[index] = state & 0xff + } + const source = new Uint8Array(await sharp(pixels, { + raw: { width: side, height: side, channels: 3 }, + }).png().toBuffer()) + const attachment = await attachments.saveImage({ data: source, mediaType: 'image/png' }) + + const request = await attachments.readImageRequest(attachment, { maxPixels: 640_000, maxBytes: 1024 * 1024 }) + + expect(request).toMatchObject({ width: 800, height: 800 }) + expect(request.bytes).toBeLessThanOrEqual(1024 * 1024) + }) + + it('shares one request transform between concurrent callers without sharing cancellation', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'shared.png', + }) + const run = vi.spyOn(CompressionLimiter.prototype, 'run') + const controller = new AbortController() + const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + + const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) + const completed = attachments.readImageRequest(attachment, policy) + const reason = new Error('cancel one waiter') + controller.abort(reason) + + await expect(cancelled).rejects.toBe(reason) + await expect(completed).resolves.toMatchObject({ width: 1130, height: 565 }) + expect(run).toHaveBeenCalledTimes(1) + run.mockRestore() + }) + + it('aborts the underlying request transform after its only waiter cancels', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'cancelled.png', + }) + let readSignal: AbortSignal | undefined + const read = vi.spyOn(attachments, 'readImage').mockImplementation((_ref, signal) => { + readSignal = signal + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new Error('request transform aborted', { cause: signal.reason })) + }, { once: true }) + }) + }) + const controller = new AbortController() + const request = attachments.readImageRequest( + attachment, + { maxPixels: 640_000, maxBytes: 1024 * 1024 }, + controller.signal, + ) + await vi.waitFor(() => { + expect(read).toHaveBeenCalledTimes(1) + }) + + const reason = new Error('cancel only transform waiter') + controller.abort(reason) + + await expect(request).rejects.toBe(reason) + expect(readSignal?.reason).toBe(reason) + }) + + it('normalizes a non-Error cancellation and replaces an aborted shared transform', async () => { + const attachments = await store() + const attachment = await attachments.saveImage({ + data: await image(2048, 1024), mediaType: 'image/png', name: 'replace.png', + }) + const actualRead = attachments.readImage.bind(attachments) + let calls = 0 + vi.spyOn(attachments, 'readImage').mockImplementation((ref, signal) => { + calls += 1 + if (calls === 1) { + return new Promise((_resolve, reject) => { + signal?.addEventListener('abort', () => { + reject(new Error('request transform aborted', { cause: signal.reason })) + }, { once: true }) + }) + } + return actualRead(ref, signal) + }) + const controller = new AbortController() + const policy = { maxPixels: 640_000, maxBytes: 1024 * 1024 } + const cancelled = attachments.readImageRequest(attachment, policy, controller.signal) + await vi.waitFor(() => { + expect(calls).toBe(1) + }) + + controller.abort('cancelled') + const replacement = attachments.readImageRequest(attachment, policy) + + await expect(cancelled).rejects.toMatchObject({ + message: 'Attachment request cancelled with a non-Error reason.', + cause: 'cancelled', + }) + await expect(replacement).resolves.toMatchObject({ width: 1130, height: 565 }) + expect(calls).toBe(2) + }) + +}) diff --git a/packages/attachment/attachment-local/tests/store.spec.ts b/packages/attachment/attachment-local/tests/store.spec.ts index a5b831e933..3ff58eb2da 100644 --- a/packages/attachment/attachment-local/tests/store.spec.ts +++ b/packages/attachment/attachment-local/tests/store.spec.ts @@ -7,7 +7,8 @@ import { mkdtemp, rm } from 'node:fs/promises' import { afterEach, describe, expect, it, vi } from 'vitest' import sharp from 'sharp' import type { ImageAttachmentLimits } from '@deepseek-ai/dsh-attachment' -import { readImageFile, saveImageFile } from '../src/store.ts' +import type { NormalizationPolicy } from '../src/normalization.ts' +import { commitPreparedImageFile, prepareImageFile, readImageFile, saveImageFile } from '../src/store.ts' const fsControl = vi.hoisted(() => ({ readSignals: [] as AbortSignal[], @@ -34,10 +35,12 @@ vi.mock('node:fs/promises', async (importOriginal) => { }) const PNG = Uint8Array.from(Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAADElEQVQImWNgZGIGAAAOAAeCcsnOAAAAAElFTkSuQmCC', 'base64', )) +const POLICY: NormalizationPolicy = { maxPixels: 2048 * 2048, maxDimension: 8192, maxBytes: 1024 * 1024 } + const LIMITS: ImageAttachmentLimits = { maxImageBytes: 1024, maxImagesPerMessage: 2, @@ -79,7 +82,7 @@ describe('local attachment store', () => { const bucket = join(objects, sha256.slice(0, 2)) fsControl.syncedDirectories.length = 0 - await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) // Each process first proves DSH_HOME durable all the way to the filesystem // root; existence alone cannot vouch for a concurrent creator's fsync. @@ -104,7 +107,7 @@ describe('local attachment store', () => { it('creates and persists a missing nested home directory against the filesystem root', async () => { const storageRoot = join(await root(), 'home', 'attachments', 'v1') - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) @@ -113,8 +116,8 @@ describe('local attachment store', () => { const storageRoot = await root() const first = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '/private/tmp/pixel.png', - }, LIMITS) - const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + }, LIMITS, POLICY) + const second = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const sha256 = createHash('sha256').update(PNG).digest('hex') const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) @@ -129,22 +132,56 @@ describe('local attachment store', () => { expect(second.attachmentId).toBe(first.attachmentId) expect(new Uint8Array(await readFile(object))).toEqual(PNG) if (process.platform !== 'win32') { - expect((await stat(object)).mode & 0o777).toBe(0o600) + expect((await stat(object)).mode & 0o777).toBe(0o400) expect((await stat(join(storageRoot, 'objects', sha256.slice(0, 2)))).mode & 0o777).toBe(0o700) } + await chmod(object, 0o600) + await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + if (process.platform !== 'win32') expect((await stat(object)).mode & 0o777).toBe(0o400) await expect(readImageFile(storageRoot, first)).resolves.toEqual({ ref: first, data: PNG }) }) + it.skipIf(process.platform !== 'win32')('publishes a new object on Windows', async () => { + const storageRoot = await root() + + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + + await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) + }) + + it('stores the normalized image of an oversized source and reads it back verified', async () => { + const storageRoot = await root() + const oversized = new Uint8Array(await sharp({ + create: { width: 4, height: 4, channels: 3, background: { r: 9, g: 9, b: 9 } }, + }).png().toBuffer()) + + const saved = await saveImageFile(storageRoot, { + data: oversized, mediaType: 'image/png', name: 'big.png', + }, { ...LIMITS, maxImagePixels: 64 }, { maxPixels: POLICY.maxPixels, maxDimension: 2, maxBytes: 1024 * 1024 }) + + expect(saved).toMatchObject({ + mediaType: 'image/jpeg', + width: 2, + height: 2, + name: 'big.png', + originalDimensions: { width: 4, height: 4 }, + }) + expect(saved.bytes).not.toBe(oversized.byteLength) + const read = await readImageFile(storageRoot, saved) + expect(read.data.byteLength).toBe(saved.bytes) + expect(String(saved.attachmentId)).toBe(`sha256:${createHash('sha256').update(read.data).digest('hex')}`) + }) + it('keeps admitted history readable after deployment limits become stricter', async () => { const storageRoot = await root() - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, ref)).resolves.toEqual({ ref, data: PNG }) }) it('forwards read cancellation to the filesystem and preserves its reason', async () => { const storageRoot = await root() - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const controller = new AbortController() fsControl.readSignals.length = 0 @@ -160,35 +197,35 @@ describe('local attachment store', () => { const storageRoot = await root() await expect(saveImageFile(storageRoot, { data: new Uint8Array(0), mediaType: 'image/png', - }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) await expect(saveImageFile(storageRoot, { data: Uint8Array.of(1, 2, 3), mediaType: 'image/png', - }, LIMITS)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'INVALID_IMAGE' }) await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/jpeg', - }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TYPE_MISMATCH' }) await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', - }, { ...LIMITS, maxImageBytes: 1 })).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) + }, { ...LIMITS, maxImageBytes: 1 }, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TOO_LARGE' }) const wide = new Uint8Array(await sharp({ create: { width: 5, height: 5, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 1 } }, }).png().toBuffer()) await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', - }, LIMITS)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) + }, LIMITS, POLICY)).rejects.toMatchObject({ code: 'IMAGE_TOO_MANY_PIXELS' }) await expect(saveImageFile(storageRoot, { data: wide, mediaType: 'image/png', - }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 })).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) + }, { ...LIMITS, maxImagePixels: 25, maxImageDimension: 4 }, POLICY)).rejects.toMatchObject({ code: 'IMAGE_DIMENSION_TOO_LARGE' }) const unnamed = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png', name: '\u0000', - }, LIMITS) + }, LIMITS, POLICY) expect(unnamed).not.toHaveProperty('name') }) it('fails closed when an object is missing, corrupted, or addressed by an invalid reference', async () => { const storageRoot = await root() - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) const sha256 = String(ref.attachmentId).slice('sha256:'.length) const object = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await chmod(object, 0o600) @@ -216,11 +253,11 @@ describe('local attachment store', () => { const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await mkdir(join(storageRoot, 'objects', sha256.slice(0, 2)), { recursive: true }) await writeFile(target, Uint8Array.of(1, 2, 3)) - await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) await writeFile(target, PNG) - const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS) + const ref = await saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) await expect(readImageFile(storageRoot, { ...ref, width: ref.width + 1 })) .rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) }) @@ -231,7 +268,17 @@ describe('local attachment store', () => { const target = join(storageRoot, 'objects', sha256.slice(0, 2), sha256) await mkdir(target, { recursive: true }) - await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS)) + await expect(saveImageFile(storageRoot, { data: PNG, mediaType: 'image/png' }, LIMITS, POLICY)) .rejects.toMatchObject({ code: 'ATTACHMENT_WRITE_FAILED' }) }) + + it('rejects prepared bytes that no longer match their content-addressed reference', async () => { + const storageRoot = await root() + const prepared = await prepareImageFile({ data: PNG, mediaType: 'image/png' }, LIMITS, POLICY) + + await expect(commitPreparedImageFile(storageRoot, { + ...prepared, + data: Uint8Array.of(...prepared.data, 0), + })).rejects.toMatchObject({ code: 'ATTACHMENT_CORRUPT' }) + }) }) diff --git a/packages/attachment/attachment/README.i18n.yaml b/packages/attachment/attachment/README.i18n.yaml index fd02d455a4..e67d95604d 100644 --- a/packages/attachment/attachment/README.i18n.yaml +++ b/packages/attachment/attachment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/attachment/attachment/README.md -README.md: 19232bd4bb86ed33e56fcdca93999967822422ab -README.zh.md: e5e7aab7c1af30b2b101bdcd218044cd1095ae0d +README.md: 01a5d6ee143ff53175dd7327cd6d419af61938a3 +README.zh.md: 1a1d297297a6815ce5338391af0182e471f99000 diff --git a/packages/attachment/attachment/README.md b/packages/attachment/attachment/README.md index 19232bd4bb..01a5d6ee14 100644 --- a/packages/attachment/attachment/README.md +++ b/packages/attachment/attachment/README.md @@ -1,16 +1,104 @@ +--- +description: "Durable image attachments for users and maintainers attaching, reusing, or debugging images in prompts and commands." +kind: "package-reference" +--- + # @deepseek-ai/dsh-attachment English | [中文](README.zh.md) -The durable attachment seam. `ctx.attachments` validates and durably commits immutable image bytes, then returns a serializable `ImageAttachmentRef`; consumers never persist browser paths, object URLs, provider URLs, or base64 in session events. +## Summary -Unsent composer images remain browser-owned temporary drafts. `validateImage` runs the same admission policy without persisting. `saveImages` owns batch count and aggregate-byte limits, validates every member before writing any member, then commits in order and returns references only after the complete batch succeeds. A later storage failure returns no partial references, although an earlier immutable content-addressed object may remain unreachable until reference-aware garbage collection exists. `AttachmentError.code` uses the closed `AttachmentErrorCode` string union. Its `ImageAdmissionErrorCode` subset marks caller-correctable image-input failures; `isImageAdmissionError` recognizes that subset at runtime so each protocol adapter can map its own error vocabulary. `saveImage` commits one accepted image before any model-visible session event is published, and `readImage` verifies the content-addressed object against its logged metadata. Callers may cancel `readImage`; implementations observe cancellation around backend and verification work and preserve it instead of translating it into a storage failure. +You can attach images to prompts and commands, and the harness keeps provider-independent normalized versions durably: each source image is admitted and normalized before your message is processed, reappears in conversation history, and is projected to the selected model route in later turns of the same session. The shipped `dsh` composition enables this with no setup. Attached images survive restarts, while browser paths, provider URLs, local storage paths, and base64 never enter durable session events. Only raster formats (PNG, JPEG, WebP, GIF) are accepted, and unsent composer drafts stay in the browser until you submit. Stored images are never deleted automatically, and non-image files, audio, and video are not supported yet. -`admitEncodedImages(attachments, images)` is the shared wire entry used by every RPC endpoint that accepts browser uploads (the session prompt endpoint and the command executor): it enforces canonical base64 on every member, then delegates batch admission — limits, validation, ordered commit — to `saveImages`. The base64 upload form is `EncodedImageAttachment`, exported from `@deepseek-ai/dsh-attachment/types` so wire contracts can reference it. +## Table of Contents +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Image attachments work end to end: attach an image to a prompt or a command, and it is saved, shown in history, and sent to the model without any further action from you. In the default `dsh` composition everything is already wired; when you compose your own setup, one plugin enables the capability. + +### Attach images to a prompt + +Attach one or more images to a user prompt in the client UI. Each source is checked, normalized to a provider-independent 8-bit sRGB/sRGBA raster, and saved before your message is processed; if any image is refused, the whole message fails and nothing is published. Supported source formats are PNG, JPEG, WebP, and GIF; a deployment controls source limits separately from normalized-storage and route-specific request limits. The one plugin below enables durable image attachments (the shipped base composition already mounts it): + +```yaml +- name: '@deepseek-ai/dsh-attachment-local' +``` + +### Pass images to commands + +Commands that accept image input receive attached images the same way. If a command does not accept images, the harness refuses with an error message instead of silently dropping them. + +### Reuse images across the session + +Saved normalized images stay in conversation history and are projected into deterministic, route-sized request versions in later turns; after a restart, a resumed session shows and reuses the same images. When the current execution filesystem maps the stored host object, the request descriptor also carries a read-only process path that the model can inspect. When history or a request version is read back, the stored bytes are checked against what was recorded, so a missing, corrupted, or swapped image surfaces as an error rather than wrong bytes. + +### What can go wrong + +An image can be refused when you attach it — unsupported format, over the size, pixel, or dimension limits, or bytes that do not match their declared type — and the message then fails as a whole. Later, a history read can fail if the stored image was deleted or corrupted on disk. Failures carry stable codes so the client and protocol adapters can explain them in their own words. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains the design decisions behind the seam and the service operations that realize the user-visible behavior; observable behavior is fully covered in [Use this package](#use-this-package). + +### Design decisions + +- **Normalize and persist before event.** Every source is prepared and verified before the batch publishes in order, so the session log never references a partial or failed normalization. +- **Immutable and retention-neutral.** Objects are immutable once published; resumed and forked sessions may share them, so reference-aware garbage collection is deferred rather than tied to any one session's deletion. +- **Verify on read.** Reads check bytes and metadata against the logged reference before returning them, and request projections fully decode cached bytes, so a missing, corrupted, or swapped object fails closed. +- **Role-neutral image blocks.** The `ImageBlock` content block in `dsh-llm` carries an `ImageAttachmentRef`; provider adapters resolve it into deterministic request versions with explicit pixel and byte budgets, while execution filesystems may map the immutable host object to a model-readable process path. +- **Error routing by code.** `AttachmentError` re-implements the `HarnessError` shape instead of extending it because the base lives in `dsh-llm`, which depends on this package; consumers route on `code`, never on the prototype chain. + +### Service operations + +The service family runs one admission-and-storage flow: every entry point enforces source batch limits and canonical base64, prepares provider-independent normalized attachments before publishing any member, and commits them durably in input order without partial results. `readImageRequest` derives deterministic route-sized variants whose identity includes the attachment id, transform version, pixel and byte budgets, and encoder settings. The pure `requestImageDimensions` export computes each projection's aspect-preserving dimensions from a total-pixel budget, so providers and request pricing share one geometry. `imageHostPath` exposes an implementation-owned host location only to trusted same-process consumers that need execution-world mapping. Callers compose ordered batches while the implementation owns compression concurrency, caching, and singleflight. Reads and projections preserve caller cancellation. Failures carry stable machine-readable codes, and the caller-correctable admission subset is recognizable at runtime so each protocol adapter maps its own vocabulary; the exact per-operation contracts live in [`src/index.ts`](src/index.ts) and [`src/error.ts`](src/error.ts). + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Plugin entry: abstract `AttachmentStore` service and re-exports | +| [`src/types.ts`](src/types.ts) | Durable vocabulary: references, limits, upload and store payloads | +| [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`: canonical-base64 enforcement, then `saveImages` delegation | +| [`src/error.ts`](src/error.ts) | `AttachmentError` class and the `isImageAdmissionError` runtime subset | +| [`src/brand.ts`](src/brand.ts) | `AttachmentId` branded opaque identifier | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; implementations enforce immutable-store checks) | + +
+ +----- + + +## Further Exploration + +For the full service contract and payload types, read the subsystem reference; for the storage that backs this capability, read the local backend. + +- [Attachment subsystem reference](../../../docs/subsystems/attachment.md) — service contract, payload types, and the `ctx.attachments` cordis surface. +- [Local filesystem backend](../attachment-local/README.md) — where your attached images are stored on this machine. +- [Capability seams](../../../docs/capability-seams.md) — how this capability family is split into roles. + +----- + + ## Model Experience -Indirectly, through the role-neutral core `ImageBlock` and provider adapters that resolve its durable reference. +Indirectly, through the provider adapter, which resolves each durable reference into an exact request version and sends its stable attachment id and actual dimensions beside the image. When the execution filesystem maps the stored object, the descriptor also includes a read-only process path and a matching extension for a writable copy. #### KV Cache effect @@ -18,6 +106,29 @@ Adding an image changes the provider request and therefore invalidates the affec ## Known Limitations and Deferred Work -- Version one accepts PNG, JPEG, WebP, and GIF only. -- Retention and garbage collection are deferred because resumed and forked sessions may share immutable objects. -- Generic files, audio, video, and persistent unsent drafts require separate lifecycle and provider contracts. + + + +These limits describe what image attachments can and cannot do; they are current package constraints, not a task backlog. + +- **Raster images only** — PNG, JPEG, WebP, and GIF are accepted; generic files, audio, and video are not supported yet. +- **Images are never deleted** — stored images are retained indefinitely; nothing removes them automatically. +- **Unsent drafts are not saved** — a composer draft stays in the browser until you submit the message. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is working context for maintainers: undecided directions and open questions. It is explicitly non-authoritative — shipped behavior and limits live in the sections above and the package code. + +#### Future: reference-aware garbage collection + +Resumed and forked sessions may share immutable objects, so any retention policy needs a reference model that accounts for session lineage before objects can be collected. No decision is recorded yet; the local backend currently retains everything. + +#### Future: non-image attachments and assistant-side output + +Generic files, audio, and video would need separate lifecycle and provider contracts, and the role-neutral `ImageBlock` leaves assistant-side image output as forward compatibility — current production adapters declare text-only output, so only user content carries images. Both directions are undecided. + +
diff --git a/packages/attachment/attachment/README.zh.md b/packages/attachment/attachment/README.zh.md index e5e7aab7c1..1a1d297297 100644 --- a/packages/attachment/attachment/README.zh.md +++ b/packages/attachment/attachment/README.zh.md @@ -1,23 +1,134 @@ +--- +description: "持久图片附件,供用户与维护者在提示词与命令中附加、复用或排查图片。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-attachment [English](README.md) | 中文 -持久附件服务边界。`ctx.attachments` 校验并持久提交不可变图片字节,随后返回可序列化的 `ImageAttachmentRef`;消费方绝不会在会话事件中持久保存浏览器路径、对象 URL、提供方 URL 或 base64。 +## 概述 -未发送的输入区图片仍是由浏览器持有的临时草稿。`validateImage` 运行相同的准入策略,但不执行持久化。`saveImages` 负责批次图片数量和总字节限制,先校验全部成员,再按顺序提交,并且只在完整批次成功后返回引用。后续存储失败不会返回部分引用,但较早写入的不可变内容寻址对象可能保持不可达,直至具备按引用感知的垃圾回收。`AttachmentError.code` 使用封闭的 `AttachmentErrorCode` 字符串联合类型。其 `ImageAdmissionErrorCode` 子集标记可由调用方修正的图片输入失败;`isImageAdmissionError` 在运行时识别该子集,使每个协议适配器可以映射自己的错误词汇。`saveImage` 会在发布任何模型可见的会话事件前提交一张已接受的图片,`readImage` 则根据已记录的元数据校验内容寻址对象。调用方可以取消 `readImage`;实现会在后端读取与校验工作的边界观察取消,并保留取消语义,而不会将其转换为存储失败。 +你可以把图片附加到提示词和命令中,harness 会持久保存提供方无关的规范化版本:每张源图都会在你的消息被处理前准入并规范化,重新出现在对话历史中,并在同一会话的后续轮次投影到所选模型路由。随附的 `dsh` 组合无需任何配置即可支持这一点。已附加的图片在重启后依然存在,而浏览器路径、提供方 URL、本地存储路径与 base64 绝不会进入持久会话事件。只接受光栅格式(PNG、JPEG、WebP、GIF),未发送的输入区草稿在提交前仍留在浏览器中。已存储的图片永远不会被自动删除,通用文件、音频和视频暂不支持。 -`admitEncodedImages(attachments, images)` 是每个接受浏览器上传的 RPC 端点(会话 prompt 端点与命令执行器)共用的 wire 入口:它对每个成员强制执行规范 base64,随后把批量准入——限额、校验、有序提交——委托给 `saveImages`。base64 上传形式为 `EncodedImageAttachment`,从 `@deepseek-ai/dsh-attachment/types` 导出,供 wire 契约引用。 +## 目录 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +图片附件端到端可用:把图片附加到提示词或命令,它会自动保存、显示在历史中并发送给模型,无需你再做任何操作。在默认 `dsh` 组合中一切都已接好;自行组合时,一个插件即可启用该能力。 + +### 在提示词中附加图片 + +在客户端 UI 中向用户提示词附加一张或多张图片。每个源图都会在你的消息被处理前接受检查、规范化为提供方无关的 8-bit sRGB/sRGBA 光栅并保存;如果任何一张图片被拒绝,整条消息都会失败且不会发布任何内容。支持的源格式为 PNG、JPEG、WebP 与 GIF;部署方分别控制源图限制、规范化存储限制与路由专用请求限制。下面这一个插件即可启用持久图片附件(随附的 base 组合已经挂载它): + +```yaml +- name: '@deepseek-ai/dsh-attachment-local' +``` + +### 把图片传给命令 + +接受图片输入的命令以相同方式接收附加图片。如果某个命令不接受图片,harness 会以错误消息拒绝,而不是静默丢弃。 + +### 在整个会话中复用图片 + +已保存的规范化图片会保留在对话历史中,并在后续轮次投影为确定性的路由尺寸请求版本;重启后,恢复的会话会显示并复用相同的图片。当前执行文件系统可以映射已存宿主对象时,请求描述符还会携带模型可检查的只读进程路径。回读历史或请求版本时,已存储的字节会与记录的内容比对,因此缺失、损坏或被替换的图片会以错误形式呈现,而不是错误的字节。 + +### 可能出什么问题 + +附加图片时可能被拒绝——格式不受支持、超出大小、像素或尺寸限制,或者字节与声明类型不符——此时整条消息失败。之后,如果磁盘上的图片被删除或损坏,历史读取也可能失败。失败带有稳定错误码,客户端与协议适配器可以用自己的措辞解释它们。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释 seam 背后的设计决策,以及实现用户可见行为的服务操作;可观察行为已在[使用本包](#use-this-package)中完整说明。 + +### 设计决策 + +- **事件前完成规范化与持久化。** 每个源图都会在批次按序发布前完成准备与校验,因此会话日志绝不会引用部分完成或规范化失败的对象。 +- **不可变且保留策略中立。** 对象一经发布即不可变;恢复和 fork 后的会话可能共享它们,因此引用感知的垃圾回收被推迟,而不是与任何单个会话的删除绑定。 +- **读取时校验。** 读取在返回前把字节和元数据与记录的引用比对,请求投影还会完整解码缓存字节,因此缺失、损坏或被替换的对象都会失败关闭。 +- **角色无关的图片块。** `dsh-llm` 中的 `ImageBlock` 内容块携带 `ImageAttachmentRef`;提供方适配器以显式像素与字节预算把引用解析为确定性请求版本,执行文件系统则可以把不可变宿主对象映射为模型可读的进程路径。 +- **按错误码路由。** `AttachmentError` 重新实现 `HarnessError` 的结构而不是继承它,因为基类位于 `dsh-llm`,而后者依赖本包;消费方按 `code` 路由,绝不依赖原型链。 + +### 服务操作 + +服务族运行同一条准入与存储流程:每个入口都强制执行源批次限制与规范 base64,在发布任何成员前准备提供方无关的规范化附件,再按输入顺序持久提交而不产生部分结果。`readImageRequest` 派生确定性的路由尺寸变体,其身份包含附件 id、变换版本、像素与字节预算及编码参数。纯函数导出 `requestImageDimensions` 会按总像素预算计算每个投影保持宽高比的尺寸,使提供方与请求定价共享同一套几何计算。`imageHostPath` 只向需要执行世界映射的受信任同进程消费方暴露实现拥有的宿主位置。调用方组合有序批次,而实现拥有压缩并发、缓存与 singleflight。读取和投影保留调用方的取消语义。失败带有稳定且机器可读的错误码,运行时即可识别可由调用方修正的准入子集,让每个协议适配器映射自己的词汇;各操作的确切约定见 [`src/index.ts`](src/index.ts) 与 [`src/error.ts`](src/error.ts)。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `AttachmentStore` 服务与再导出 | +| [`src/types.ts`](src/types.ts) | 持久词汇:引用、限额、上传与存储载荷 | +| [`src/admission.ts`](src/admission.ts) | `admitEncodedImages`:规范 base64 强制,随后委托 `saveImages` | +| [`src/error.ts`](src/error.ts) | `AttachmentError` 类与 `isImageAdmissionError` 运行时子集 | +| [`src/brand.ts`](src/brand.ts) | `AttachmentId` 带类型标记的不透明标识符 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;实现负责强制不可变存储检查) | + +
+ +----- + + +## 进一步探索 + +完整的服务约定与载荷类型请看子系统参考;支撑这一能力的存储请看本地后端。 + +- [附件子系统参考](../../../docs/subsystems/attachment.zh.md)——服务约定、载荷类型与 `ctx.attachments` 的 cordis 接口面。 +- [本地文件系统后端](../attachment-local/README.zh.md)——你的附加图片在本机上的存储位置。 +- [能力 seam](../../../docs/capability-seams.zh.md)——本能力家族如何拆分为多个角色。 + +----- + + ## 模型体验 -该包通过角色无关的核心 `ImageBlock`,以及解析其持久引用的提供方适配器,间接影响模型。 +该包通过提供方适配器间接影响模型;适配器会把每个持久引用解析为确切请求版本,并在图片旁发送稳定附件 id 与实际尺寸。执行文件系统可以映射已存对象时,描述符还会包含只读进程路径,以及可写副本使用的匹配扩展名。 -#### KV 缓存影响 +#### KV Cache 影响 添加图片会改变提供方请求,因此会使受影响的请求后缀失效。 -## 已知限制与待完成工作 +## 已知限制与延期工作 -- 第一版仅接受 PNG、JPEG、WebP 和 GIF。 -- 保留策略与垃圾回收尚未实现,因为恢复和 fork 后的会话可能共享不可变对象。 -- 通用文件、音频、视频和持久的未发送草稿需要单独的生命周期与提供方契约。 + + + +这些限制描述了图片附件能做什么、不能做什么;它们是当前包约束,而非任务积压。 + +- **仅支持光栅图片**——接受 PNG、JPEG、WebP 与 GIF;通用文件、音频和视频暂不支持。 +- **图片永远不会被删除**——已存储的图片无限期保留;没有任何机制自动移除它们。 +- **未发送的草稿不会保存**——输入区草稿在提交消息前一直留在浏览器中。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +本开发备注是维护者的工作上下文:尚未决定的探索方向与开放问题。它明确不具权威性——已交付的行为与限制以上文和包代码为准。 + +#### 未来:引用感知的垃圾回收 + +恢复和 fork 后的会话可能共享不可变对象,因此任何保留策略都需要一个能考虑会话血缘的引用模型,之后才能回收对象。目前尚未记录任何决定;本地后端当前保留一切。 + +#### 未来:非图片附件与助手侧输出 + +通用文件、音频与视频需要单独的生命周期与提供方契约;角色无关的 `ImageBlock` 也把助手侧图片输出留作前瞻兼容——当前生产适配器声明只输出文本,因此只有用户内容携带图片。两个方向都尚未决定。 + +
diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 87764ef5e2..1abd03e3e0 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/attachment/attachment/src/brand.ts b/packages/attachment/attachment/src/brand.ts index 6df4014f74..e783076982 100644 --- a/packages/attachment/attachment/src/brand.ts +++ b/packages/attachment/attachment/src/brand.ts @@ -13,3 +13,15 @@ export type AttachmentId = Branded<'AttachmentId'> export function AttachmentId(value: string): AttachmentId { return value as AttachmentId } + +/** Opaque deterministic identity for one request-image transformation. */ +export type ImageVariantId = Branded<'ImageVariantId'> + +/** + * Brand a validated request-image transformation identifier. + * @param value - attachment-provider-produced opaque identifier. + * @returns the branded identifier. + */ +export function ImageVariantId(value: string): ImageVariantId { + return value as ImageVariantId +} diff --git a/packages/attachment/attachment/src/error.ts b/packages/attachment/attachment/src/error.ts index 2e2d695dae..c19229872b 100644 --- a/packages/attachment/attachment/src/error.ts +++ b/packages/attachment/attachment/src/error.ts @@ -23,6 +23,7 @@ export type AttachmentErrorCode = | 'ATTACHMENT_WRITE_FAILED' | 'ATTACHMENT_NOT_FOUND' | 'ATTACHMENT_READ_FAILED' + | 'ATTACHMENT_PROJECTION_UNSUPPORTED' /** Runtime membership for structurally compatible errors crossing package boundaries. */ const IMAGE_ADMISSION_ERROR_CODE_SET: ReadonlySet = new Set(IMAGE_ADMISSION_ERROR_CODES) diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 1480751c15..4ee001b86c 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -5,20 +5,25 @@ import { AttachmentError } from './error.ts' import type { ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, + RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' -export { AttachmentId } from './brand.ts' +export { AttachmentId, ImageVariantId } from './brand.ts' export { AttachmentError, isImageAdmissionError } from './error.ts' export type { AttachmentErrorCode, ImageAdmissionErrorCode } from './error.ts' export { admitEncodedImages } from './admission.ts' +export { requestImageDimensions } from './request-projection.ts' export type { AttachmentId as AttachmentIdType, EncodedImageAttachment, ImageAttachmentLimits, ImageAttachmentRef, + ImageRequestPolicy, ImageMediaType, + RequestImageAttachment, SaveImageAttachment, StoredImageAttachment, } from './types.ts' @@ -54,7 +59,7 @@ export abstract class AttachmentStore extends Service { * @param inputs - encoded images in their owning message order. * @returns durable references in the exact input order. */ - async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + protected validateImageBatch(inputs: readonly SaveImageAttachment[]): void { const { maxImagesPerMessage, maxMessageImageBytes, mediaTypes } = this.imageLimits if (inputs.length > maxImagesPerMessage) { throw new AttachmentError('Image batch exceeds the configured image-count limit.', 'TOO_MANY_IMAGES') @@ -68,6 +73,15 @@ export abstract class AttachmentStore extends Service { throw new AttachmentError(`Image type ${input.mediaType} is not accepted by this deployment.`, 'UNSUPPORTED_IMAGE_TYPE') } } + } + + /** + * Validate and durably commit one ordered image batch. + * @param inputs - encoded images in owning-message order. + * @returns durable normalized attachment references in the same order after every member succeeds. + */ + async saveImages(inputs: readonly SaveImageAttachment[]): Promise { + this.validateImageBatch(inputs) for (const input of inputs) await this.validateImage(input) const refs: ImageAttachmentRef[] = [] @@ -77,8 +91,11 @@ export abstract class AttachmentStore extends Service { /** * Validate and durably commit one image before its owning session event is appended. + * The returned reference describes the persisted normalized image. When + * normalization reduces the raster, its `originalDimensions` records the + * orientation-applied input dimensions. * @param input - encoded bytes, declared media type, and optional display name. - * @returns a durable content-addressed reference. + * @returns the durable content-addressed normalized image reference. */ abstract saveImage(input: SaveImageAttachment): Promise @@ -86,10 +103,43 @@ export abstract class AttachmentStore extends Service { * Read one image and verify that bytes still match the recorded reference. * @param ref - durable reference from the session log. * @param signal - optional cancellation for backend read and verification work. - * @returns the verified bytes and canonical reference. + * @returns the verified bytes and normalized attachment reference. * @throws the signal reason when aborted, or a storage error when verification fails. */ abstract readImage(ref: ImageAttachmentRef, signal?: AbortSignal): Promise + + /** + * Locate the provider-owned normalized object in the harness host filesystem. + * @param ref - durable normalized attachment reference. + * @returns an absolute host path, or undefined when this backend is not host-file-backed. + * @throws an AttachmentError when the durable reference is invalid. + */ + imageHostPath(ref: ImageAttachmentRef): string | undefined { + void ref + return undefined + } + + /** + * Generate or read one deterministic model-request version from the stored normalized image. + * @param ref - durable provider-independent normalized attachment reference. + * @param policy - exact route pixel budget and encoded-byte target; a target no ladder quality meets yields the smallest ladder output. + * @param signal - optional cancellation. + * @returns request bytes and the cache/upload identity covering every transform input. + */ + readImageRequest( + ref: ImageAttachmentRef, + policy: ImageRequestPolicy, + signal?: AbortSignal, + ): Promise { + signal?.throwIfAborted() + void ref + void policy + return Promise.reject(new AttachmentError( + 'The mounted attachment provider cannot derive model-request images.', + 'ATTACHMENT_PROJECTION_UNSUPPORTED', + )) + } + } export default AttachmentStore diff --git a/packages/attachment/attachment/src/request-projection.ts b/packages/attachment/attachment/src/request-projection.ts new file mode 100644 index 0000000000..ac9a56c983 --- /dev/null +++ b/packages/attachment/attachment/src/request-projection.ts @@ -0,0 +1,36 @@ +/** + * Pure request-projection geometry shared by attachment providers and + * provider-side request pricing. @module @deepseek-ai/dsh-attachment/request-projection + */ + +/** + * Compute aspect-preserving integer dimensions within a hard total-pixel budget. + * @param width - positive source width. + * @param height - positive source height. + * @param maxPixels - positive width-times-height cap. + * @returns inward-rounded dimensions; small images are not enlarged. + */ +export function requestImageDimensions( + width: number, + height: number, + maxPixels: number, +): { width: number; height: number } { + const scale = Math.min(1, Math.sqrt(maxPixels / (width * height))) + if (scale === 1) return { width, height } + if (width >= height) { + let projectedWidth = Math.max(1, Math.floor(width * scale)) + let projectedHeight = Math.max(1, Math.round(projectedWidth * height / width)) + while (projectedWidth * projectedHeight > maxPixels && projectedWidth > 1) { + projectedWidth -= 1 + projectedHeight = Math.max(1, Math.round(projectedWidth * height / width)) + } + return { width: projectedWidth, height: projectedHeight } + } + let projectedHeight = Math.max(1, Math.floor(height * scale)) + let projectedWidth = Math.max(1, Math.round(projectedHeight * width / height)) + while (projectedWidth * projectedHeight > maxPixels && projectedHeight > 1) { + projectedHeight -= 1 + projectedWidth = Math.max(1, Math.round(projectedHeight * width / height)) + } + return { width: projectedWidth, height: projectedHeight } +} diff --git a/packages/attachment/attachment/src/types.ts b/packages/attachment/attachment/src/types.ts index 7c29231172..046444cd76 100644 --- a/packages/attachment/attachment/src/types.ts +++ b/packages/attachment/attachment/src/types.ts @@ -1,13 +1,13 @@ /** Durable attachment vocabulary. @module @deepseek-ai/dsh-attachment/types */ -import type { AttachmentId } from './brand.ts' +import type { AttachmentId, ImageVariantId } from './brand.ts' export type { AttachmentId } from './brand.ts' /** Raster image formats accepted by the version-one attachment path. */ export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' -/** Durable, serializable metadata for one immutable image object. */ +/** Durable, serializable reference to one immutable normalized image. */ export interface ImageAttachmentRef { /** Opaque storage identifier; never a filesystem path or bearer URL. */ attachmentId: AttachmentId @@ -21,6 +21,14 @@ export interface ImageAttachmentRef { height: number /** Optional display name stripped of local path information. */ name?: string + /** + * Input dimensions after applying EXIF orientation and before normalization + * scaling. Present only when normalization reduced the image. + */ + originalDimensions?: { + width: number + height: number + } } /** Deployment-resolved limits used by upload admission and request buffering. */ @@ -58,3 +66,31 @@ export interface StoredImageAttachment { ref: ImageAttachmentRef data: Uint8Array } + +/** Deterministic request-image policy selected by one exact model route. */ +export interface ImageRequestPolicy { + /** Maximum width multiplied by height after aspect-preserving projection. */ + maxPixels: number + /** Encoded-byte target before base64 expansion or Files API upload; the smallest quality-ladder output is kept when no quality fits. */ + maxBytes: number +} + +/** Cached request version derived from one provider-independent normalized attachment. */ +export interface RequestImageAttachment { + /** Cache and upload-index key over the attachment id, policy, and fixed encoder parameters. */ + variantId: ImageVariantId + /** Durable normalized attachment from which this request version was derived. */ + attachment: ImageAttachmentRef + /** Encoded request bytes. */ + data: Uint8Array + mediaType: ImageMediaType + bytes: number + width: number + height: number + /** Provider-compatible sample depth proven after request encoding. */ + depth: 'uchar' + /** Provider-compatible color space proven after request encoding. */ + space: 'srgb' + /** Whether the encoded request version retains an alpha channel. */ + hasAlpha: boolean +} diff --git a/packages/attachment/attachment/tests/index.spec.ts b/packages/attachment/attachment/tests/index.spec.ts index 622b797ce2..8675d45404 100644 --- a/packages/attachment/attachment/tests/index.spec.ts +++ b/packages/attachment/attachment/tests/index.spec.ts @@ -3,9 +3,12 @@ import { describe, expect, it } from 'vitest' import AttachmentStore, { AttachmentError, AttachmentId, + ImageVariantId, isImageAdmissionError, type ImageAttachmentRef, type ImageMediaType, + type ImageRequestPolicy, + type RequestImageAttachment, type SaveImageAttachment, type StoredImageAttachment, } from '../src/index.ts' @@ -48,6 +51,41 @@ class RecordingStore extends AttachmentStore { readImage(_ref: ImageAttachmentRef): Promise { throw new Error('not used') } + + override readImageRequest( + ref: ImageAttachmentRef, + _policy: ImageRequestPolicy, + ): Promise { + this.calls.push(`request:${ref.name}`) + return Promise.resolve({ + variantId: ImageVariantId(`sha256:${String(ref.bytes).padStart(64, '0')}`), + attachment: ref, + data: Uint8Array.of(ref.bytes), + mediaType: ref.mediaType, + bytes: 1, + width: ref.width, + height: ref.height, + depth: 'uchar', + space: 'srgb', + hasAlpha: false, + }) + } +} + +class UnsupportedProjectionStore extends AttachmentStore { + readonly imageLimits = LIMITS + + validateImage(): Promise { + return Promise.resolve() + } + + saveImage(): Promise { + throw new Error('not used') + } + + readImage(): Promise { + throw new Error('not used') + } } function image(value: number, mediaType: ImageMediaType = 'image/png'): SaveImageAttachment { @@ -97,6 +135,25 @@ describe('AttachmentStore.saveImages', () => { }) }) +describe('AttachmentStore.readImageRequest', () => { + it('reports unsupported request projection while preserving cancellation', async () => { + const store = new UnsupportedProjectionStore(new Context()) + const ref = await new RecordingStore(new Context()).saveImage(image(1)) + await expect(store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 })) + .rejects.toMatchObject({ code: 'ATTACHMENT_PROJECTION_UNSUPPORTED' }) + const controller = new AbortController() + const reason = new Error('cancel unsupported projection') + controller.abort(reason) + expect(() => store.readImageRequest(ref, { maxPixels: 1, maxBytes: 1 }, controller.signal)).toThrow(reason) + }) + + it('exposes no provider-owned host path by default', async () => { + const store = new RecordingStore(new Context()) + const ref = await store.saveImage(image(1)) + expect(store.imageHostPath(ref)).toBeUndefined() + }) +}) + describe('isImageAdmissionError', () => { it('separates caller-correctable image admission failures from storage faults', () => { expect(isImageAdmissionError(new AttachmentError('bad bytes', 'INVALID_IMAGE'))).toBe(true) diff --git a/packages/attachment/attachment/tests/request-projection.spec.ts b/packages/attachment/attachment/tests/request-projection.spec.ts new file mode 100644 index 0000000000..5e8a740779 --- /dev/null +++ b/packages/attachment/attachment/tests/request-projection.spec.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { requestImageDimensions } from '../src/index.ts' + +describe('request image dimensions', () => { + it.each([ + [4096, 4096, 800, 800], + [4096, 2048, 1130, 565], + [3840, 2160, 1066, 600], + [320, 240, 320, 240], + ])('projects %sx%s under 640,000 pixels as %sx%s', (width, height, expectedWidth, expectedHeight) => { + const projected = requestImageDimensions(width, height, 640_000) + expect(projected).toEqual({ + width: expectedWidth, + height: expectedHeight, + }) + expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) + }) + + it('projects a portrait within the same total-pixel budget', () => { + const projected = requestImageDimensions(2160, 3840, 640_000) + + expect(projected).toEqual({ width: 600, height: 1066 }) + expect(projected.width * projected.height).toBeLessThanOrEqual(640_000) + }) + + it('rounds a portrait inward when integer aspect rounding crosses the pixel cap', () => { + expect(requestImageDimensions(2, 4, 5)).toEqual({ width: 1, height: 2 }) + }) +}) diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 5dd74d1588..49f4b9ac97 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/README.md -README.md: cdf551729567a7ad4be9dbd99861db4ad57cd5d7 -README.zh.md: f775e9aac291ce176516c20eca773d6520050e23 +README.md: 0d13420c8408e046bc538ebb06f5f7c0d23eb1b1 +README.zh.md: ebc0f82eb79fc310d0b944f4bb965e72391ead3f diff --git a/packages/boot/README.md b/packages/boot/README.md index cdf5517295..0d13420c84 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -1,12 +1,39 @@ +--- +description: "The boot package group: how dsh app bins start — environment loading, profile and patch layers, clear startup failures, and app-owned command lines." +kind: "package-group" +--- + # boot/ — shared app-bin boot glue English | [中文](README.zh.md) -The channel-neutral boot library shared by `apps/cli` and the [`examples/`](../examples/README.md) demo bins. +## Summary + +The boot group provides what every dsh app bin needs to start: `app-boot` turns a `cordis.yml` plus your environment and patch layers into a running app with clear failure messages, and `cmdline` lets the app own its command-line flags and `--help`. With these packages you can run `dsh` or write a new application or test fixture that boots the same way. Both are libraries imported by `apps/cli` and test-only Loader fixtures, never plugins a composition loads. This page maps the group; each package README owns its per-package contract. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + + +## Packages | Package | Role | ctx key | |---|---|---| -| `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | -| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` | +| [`app-boot`](app-boot/README.md) | Boots a dsh app from a `cordis.yml`: loads `.env`, applies profile and patch layers, and reports startup failures clearly | (library for the bins) | +| [`cmdline`](cmdline/README.md) | Lets the app own its flags, `--help`, and exit code; passes everything after the launcher's flags through verbatim | `cmdlineArgs`, `appExit` | -The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). + +## Related documentation + +- [dsh app](../../apps/cli/README.md) — the `dsh` bin that consumes these helpers for its boot sequence. +- [Profile bundles](../bundle/README.md) — installable patch layers that `dsh --profile` compositions mount. +- [dsh-home-paths](../util/home-paths/README.md) — the harness-home resolver both packages build on. +- [App-owned command-line decision](../../.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md) — why an app owns its flag family instead of the launcher. + + +## Dev Note + +None. diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index f775e9aac2..ebc0f82eb7 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -1,12 +1,39 @@ +--- +description: "boot 包组:dsh app bin 如何启动——环境加载、profile 与 patch 层、清晰的启动失败信息,以及由应用持有的命令行。" +kind: "package-group" +--- + # boot/:共享的 app bin 启动粘合层 [English](README.md) | 中文 -由 `apps/cli` 和 [`examples/`](../examples/README.md) demo bin 共享、与渠道无关的启动库。 +## 概述 + +boot 组提供每个 dsh app bin 启动所需的全部能力:`app-boot` 把 `cordis.yml` 连同你的环境与 patch 层变成运行中的应用,并给出清晰的失败信息;`cmdline` 让应用持有自己的命令行 flag 与 `--help`。借助这些包,你可以运行 `dsh`,也可以编写以同样方式启动的新应用或测试 fixture。两者都是 `apps/cli` 与测试专用 Loader fixture 导入的库,绝不是组合加载的插件。本页是组的映射;各包 README 负责各自的包级约定。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + + +## 包 | 包 | 职责 | ctx 键 | |---|---|---| -| `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | -| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` | +| [`app-boot`](app-boot/README.zh.md) | 从 `cordis.yml` 启动 dsh 应用:加载 `.env`、应用 profile 与 patch 层,并清晰报告启动失败 | (供各 bin 使用的库) | +| [`cmdline`](cmdline/README.zh.md) | 让应用持有自己的 flag、`--help` 与退出码;启动器自身 flag 之后的一切原样传入 | `cmdlineArgs`、`appExit` | -启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 + +## 相关文档 + +- [dsh 应用](../../apps/cli/README.zh.md)——在其启动序列中使用这些 helper 的 `dsh` bin。 +- [Profile 组合包](../bundle/README.zh.md)——可由 `dsh --profile` 组合挂载的可安装 patch 层。 +- [dsh-home-paths](../util/home-paths/README.zh.md)——两个包都依赖的 harness home 解析器。 +- [应用持有命令行决策](../../.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md)——为什么 flag 家族由应用持有而非启动器。 + + +## 开发备注 + +无。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index 81098dca30..b980e35c55 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/app-boot/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/app-boot/README.md -README.md: 80f8e8a694b32eb8a1499f75a56de852f7106643 -README.zh.md: c8396625f2f59008d71b865f35a04ba55bb313cd +README.md: 9094950a7b154d0feb0d8bd0b76e1f06ff2a10fb +README.zh.md: 3a3ef6c82182d23a50dfa460591b20e9ebfd291e diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index 80f8e8a694..9094950a7b 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -1,60 +1,153 @@ -# `@deepseek-ai/dsh-app-boot` +--- +description: "Shared Loader boot support for dsh profiles and the temporary Python SDK runtime: environment layers, patches, diagnostics, and configuration preview." +kind: "package-library" +--- + +# @deepseek-ai/dsh-app-boot English | [中文](README.zh.md) -Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`dsh-acp-demo`](../../examples/acp-demo/README.md)): each bin is a thin self-executing composition over these helpers, parameterized by its diagnostic prefix, so loader-failure behavior has one owner instead of drifting between published artifacts. +## Summary -| Export | Role | +`dsh-app-boot` is the shared Loader boot library behind `dsh` profiles, including the CLI packaged by the Python runtime wheel. It loads environment layers, composes profile bundles and patches, boots every plugin, and returns the running app or identifies the failed plugin and cause. Product applications use the `dsh` launcher instead of publishing separate bins; direct-config helpers remain only for lower-level embedders and tests. You can preview the effective configuration before booting, select live or startup-only patch application per profile, and let a terminal-owning app restore its terminal before a fatal exit. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Starting an app with this package is a small, explicit entry point: you give it a config file and it runs the whole boot. This section covers what you can do and what you get; the helper calls behind each outcome are documented in the folded implementation section. + +### When to use it + +Use it when implementing the shared `dsh` launcher or embedding its lower-level boot helpers. Product features belong in profile bundles instead of new application bins; code that only adds plugins to an already-running app mounts those plugins directly. + +### Starting the app + +You give your entry point a config file, and the process starts the whole app: it loads your environment layers, applies patches and profiles, boots every plugin, and returns once the app is running. In replay mode it boots the sibling `cordis.snapshot.yml` instead, so a recorded session reproduces identically. The smallest entry point is two calls: + +```text +installFailLoud('dsh') +const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) +``` + +With that entry point, success looks like a running app with every plugin active; failure is never silent — one labelled line names the failing plugin and the stage, and the process exits nonzero. The app context is torn down before the error is reported, so nothing keeps running half-started. + + +### Profiles + +A profile is how one dsh installation ships different app surfaces: `web`, `headless`, `acp`, `sdk`, and `sdk-minimal` start distinct compositions from the same launcher. A profile lives at `$DSH_HOME/profiles/` and combines installable bundles, its own `cordis.patch.yml`, and `patchReload: live | startup`; omitted reload policy keeps the historical `live` default for custom profiles. The shipped `web` template uses live reload, while the other shipped templates apply patches only at startup. `sdk-minimal` names only its standalone bundle; the other templates retain base-plus-mode stacks. `dsh plugin` creates custom profiles, and a missing bundle or one without a patch declaration fails startup loudly. + +Your machine-local preferences also live in the Harness home: + +- **`.env`** — your ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. Variables that decide how the process starts (`PATH`, proxies, `DSH_*`, `XDG_*` and similar) are rejected from files: export them instead. For a non-product bin that just wants one directory's `.env`, a missing file is fine and an unloadable one prints one labelled warning line. +- **`cordis.patch.yml`** — your tweak layer, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): replace one entry's whole config (restating the fields you keep), insert new entries, or interpolate `!!js` expressions at boot. A patch naming an entry that does not exist prints a stderr warning; an empty or comments-only file fails boot — disable the layer with `[]` instead. + +Profiles with `patchReload: live` watch both user patch files: a valid edit recomposes without restart, while a rejected edit leaves the last good app running. A `startup` profile installs neither those watchers nor the launcher's watch-only HMR fallback. + +### Previewing the effective configuration + +Before you boot, you can print the exact configuration the app will mount: the dump shows the composed entry list with `!!js` expressions verbatim, grouped under comments naming each source file and the patch layers that changed it, as one loadable YAML document. Patches that match no row are reported with their layer label; a missing, unparsable, or invalid config fails the dump. + +### What you see when startup fails + +Startup failure is a single labelled line plus a nonzero exit — never a silent hang or a raw stack dump. The message names the failing plugin; a plugin that threw keeps its original error, and an entry that never started is reported with the services it was waiting for. + +If your app owns the terminal, it can hand the terminal back before the process exits, so your shell is never left in raw mode. The handoff is bounded: a stuck cleanup delays the fatal exit but never cancels it. + +### Telling the agent where the harness lives + +When your app boots a model-backed agent, you can tell the agent where the DSH implementation checkout lives: it learns that path and that it must not infer the working directory from it — it should use `pwd`. The instruction appears once near the top of the system prompt. Apps without a system prompt service skip it; in development, reloading the system prompt drops it until the next boot. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains how the outcomes above are realized and points at the code that realizes them; everything here is developer-facing and not needed to use the package. + +### Design notes + +- **Channel-neutral library.** The package carries no loader hooks and no dev-mode surface; the [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence, and built consumers use plain Node package resolution. +- **Two Loader builtins.** `mountRootInclude` registers `cordis:include` and `cordis:group` as Loader builtins: a group row gives one `isolate` realm to a provider and its consumers together, and an agent preset outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` by name. Both load through the ambient module pipeline rather than the included tree's own specifier resolution. +- **Profile module fallback.** Bare plugin specifiers resolve through the Loader from the config directory. Plain Node maintains one symlink per package in the installation dependency closure. A packaged executable instead reads each installed export map with Node ESM conditions and writes real proxy packages that re-export virtual module URLs, because an operating-system symlink cannot enter pkg's `/snapshot` tree. Missing exports stay unavailable, malformed maps fail startup, and a cross-process writer lock replaces stale entries without exposing partial proxies. A selected external bundle absent from the installation closure receives a profile-local `.dsh-module-fallback` link; existing pnpm entries win, projected links are excluded from later closure discovery, and cleanup removes only dsh-owned links. +- **One rejection checkpoint.** `assertEntriesActivated` keeps the exact reasons it folds into the boot diagnostic visible through the next process rejection checkpoint, so `installFailLoud` coalesces Loader's duplicate notification while unrelated unhandled rejections remain fatal. +- **Two-stage failure labels.** `boot()` distinguishes `host preparation failed` — `prepare` threw before any config-tree entry mounted — from `plugin tree failed to load`, and appends the deepest plugin error's stack so the startup diagnostic preserves the original activation error instead of only the wrap chain. + +### Helper behavior + +The exports each own one stage of the boot: config resolution and snapshot replay, layered environment loading, fail-loud reporting, activation auditing, patch parsing, root-include mounting, config dump rendering, live patch watching, profile composition, and the harness-source section. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts) and [`src/profile.ts`](src/profile.ts). + +### Source map + +| File | Role | |---|---| -| `resolveConfigPath(path, snapshotMode, cwd?)` | Absolute config path; `snapshotMode === 'replay'` swaps a `cordis.yml`/`.yaml` basename for its sibling `cordis.snapshot.yml` | -| `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | -| `loadLayeredEnv(binName, cwd?, warn?)` | Build the product CLI's frozen inherited > project `.env` > user `.env` snapshot, reject bootstrap-only file variables, and materialize accepted file values without replacing inherited ones | -| `installFailLoud(binName, proc?, release?)` | Turn an unhandled boot or later Loader rejection into one labelled stderr line + `exit(1)`; the optional `release` teardown is awaited between the two (bounded by `FAIL_LOUD_RELEASE_TIMEOUT_MS`) so a terminal-owning surface restores the terminal before exit; returns the uninstaller | -| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | How long `installFailLoud` waits for its `release` hook; a wedged disposer delays the fatal exit, never cancels it | -| `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber, reporting every unresolved plugin name as a Cordis startup failure | -| `assertEntriesActivated(ctx, binName)` | Include the `assertEntriesLoaded` check, then await every enabled entry after the Loader settles; throw with each failed plugin's original stack or each pending plugin's unresolved services | -| `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — a top-level YAML array of include `PatchOptions` (id-targeted config overrides, `insert` lists, `!!js` allowed); absent file → `undefined`, an unreadable/unparsable/non-array file throws | -| `loadOverlayPatches(binName, file)` | Parse a required top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative | -| `watchUserPatches(ctx, options)` | Register the named patch file with the existing Cordis HMR service; each add/change/removal transactionally recomposes the full patch list through the caller's `compose` closure (app-owned layers around the current user layer) and returns an async disposer | -| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | Create the root context, expose `dshHomePath(...segments)` to Loader `!!js` config expressions, install Loader, run optional host preparation before config-tree entries mount (`prepare` may use Loader and provide launcher-owned context slots), then mount and await the include tree, assert entries loaded and activated, and return the root context — or dispose the partial context and reject a labelled error; the optional module base has the same resolution semantics as `mountRootInclude` | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with the include's own parser and patch algorithm (`entryListSchema`/`applyEntryPatches`), so the result equals what `boot()` mounts, and render YAML with `!!js` expressions verbatim; each run of rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation failures throw | -| `addHarnessSourceSection(ctx, sourceRoot)` | Add a global `harness:source` prompt section (ordered just after the harness identity, before the persona) telling the agent the on-disk path to the DSH implementation checkout while warning it not to infer the current working directory from that path and to use `pwd` instead; a no-op returning `undefined` when the booted tree has no `systemPrompt` service. The section is registered against that service's fiber, so a dev HMR reload of the system prompt drops it until the next boot | -| `HARNESS_SOURCE_SECTION` | The `'harness:source'` section name `addHarnessSourceSection` registers under | +| [`src/index.ts`](src/index.ts) | Boot helpers: config resolution, environment loading, fail-loud guard, activation audit, patch parsing, config dump, harness-source section | +| [`src/profile.ts`](src/profile.ts) | Profile discovery, initialization, bundle resolution, module fallback | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; boundary and replay tests cover the protocol mapping) | -Loader settlement rejects import and lifecycle failures with the failing entry and stage; `boot()` disposes the partial context and wraps that failure with the bin name. Entries settlement leaves behind are audited separately: `assertEntriesLoaded` turns an enabled fiber-less entry into a rejection naming every unresolved plugin, and `assertEntriesActivated` awaits each failed fiber to include its original stack in the startup rejection and names each pending entry's unresolved services. Before throwing, the audit marks those exact rejection reasons through one process checkpoint so `installFailLoud` coalesces Loader's duplicate notification while every unrelated unhandled rejection remains fatal. +
-The Loader mounts entries concurrently, so a surface can already own the terminal when something else fails: exiting without the tree's own teardown would leave raw mode, bracketed paste, and the keyboard protocol set on the user's shell, and an in-flight terminal query's reply would land as literal text at the next prompt. A config-tree failure settles through `boot()`, whose disposal of the partial context runs the surface's own shutdown before the labelled rejection. For the rejections `boot()` cannot see — a plugin's detached async work rejecting during or after mounting — a terminal-owning bin passes `release` to dispose the tree before the exit commits; `dsh` captures the root context in `boot()`'s `prepare` hook rather than from its return value so the hook covers the whole mounting window. While a release is in flight the handler stays installed and latched: the first rejection is the reported one, and later rejections (teardown's own included) are swallowed rather than becoming uncaught and killing the process mid-teardown. +----- -`cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. + +## Further Exploration -Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. Repository bins install Loader's optional `node-addon-require-builtin` peer; external callers must supply it or install plugins where plain Node import resolution can find them. The built `dsh-app-boot` artifact embeds the statically mounted Include implementation while leaving Loader external, so the include tree and host bind to one Loader peer. The `pnpm dsh` source path additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Read these pages when the package-level contract is not enough. They move from the shared boot mechanics to the composition model and the decision evidence behind it. -This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. +- [Cordis primer](../../../docs/cordis-primer.md) — Loader, `!!js` config expressions, and include/group semantics. +- [dsh app](../../../apps/cli/README.md) — the `dsh` bin that consumes these helpers. +- [dsh-cmdline](../cmdline/README.md) — the launcher-to-app command-line handoff the bins use. +- [Profile bundles](../../bundle/README.md) — installable patch layers composed into `dsh --profile`. +- [dsh-home-paths](../../util/home-paths/README.md) — the Harness-home resolver (`resolveDshHome`). +- [Configuration source ownership](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md) — why a discovered file may not decide bootstrap behavior. +- [Profile plugin bundles](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design. -## Profiles - -A profile is a directory under `$DSH_HOME/profiles/` (the Harness home resolves through [`resolveDshHome`](../../util/home-paths/README.md): `$DSH_HOME`, else `~/.dsh`) holding a `package.json` — out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list — and the user's own `cordis.patch.yml`. A bundle is an npm package whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; `loadProfile` resolves each `dsh.profile.bundles` name two-anchored (the dsh installation first, then the profile directory) and fails loud on a listed package without a bundle declaration. `composeEntries` applies patch layers over an empty entry list through the include's own `applyEntryPatches`, so composition, flag derivation, and config dumps cannot drift from what boots. `healProfilesModuleFallback` maintains the flat `$DSH_HOME/profiles/node_modules` directory — one symlink per package the installation's app and bundles depend on — so bare plugin names in any profile resolve through Node's ordinary parent-walk without pnpm managing in-box packages. `PROFILE_TEMPLATES` (`web`, `headless`) auto-initialize on first use; other names fail loud until `initProfile` creates them (the `dsh plugin` path). `loadProfile` normalizes an exact installation-owned bundle tuple to its shipped template while preserving every other manifest field; any extra, missing, or reordered entry makes the list user-owned and leaves it unchanged. - -User-level machine-local preferences also live in the Harness home: - -- **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. -- **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): an id-targeted patch replaces the named entry's whole `config` (restate unchanged fields), `insert` adds entries, and `!!js` expressions interpolate at mount. A patch naming an entry id absent from the composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. - -Every profile boot keeps `cordis.patch.yml` live through `watchUserPatches` (a one-shot surface disposes the watcher through its bounded shutdown). The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays above). A rejected read, parse, or Loader candidate leaves the last good tree running and the HMR service broadcasts `hmr/config-update-failed(filename, Error)` after logging it; observer failures are contained. Disposing the context closes the watcher and drains an active refresh. +----- + ## Model Experience -Indirectly, through the plugin tree it loads, which determines the prompts, schemas, messages, and model adapter in the resulting application; the one export that contributes model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. +Indirectly, through the loaded plugin tree, which alone contributes model context; the one export that adds model-visible text, `addHarnessSourceSection`, does so only when a consumer calls it after boot. #### KV Cache effect -No direct invalidation from `boot()`; a consumer that calls `addHarnessSourceSection` places one short line near the system prompt's head, before per-request content, so it does not invalidate the cache across turns, and any other request-prefix change is owned by the named consumer. +Boot itself invalidates nothing in the request prefix. A consumer that calls `addHarnessSourceSection` places one short line near the system prompt's head, before per-request content, so it does not invalidate the cache across turns; any other request-prefix change is owned by the named consumer. ## Known Limitations and Deferred Work + + + +These limits describe when this boot library is a poor fit or needs special care. They are current package constraints, not a task backlog. + - **Bare package specifiers depend on Loader internals** — production bins need Loader's optional native helper; an in-process caller without it must use resolvable relative/file specifiers or provide its own module-resolution hook. - **Snapshot replay swapping is basename-specific** — only a config ending in `cordis.yml` or `cordis.yaml` maps to the sibling `cordis.snapshot.yml`; custom config names require caller-managed selection. - **Environment discovery is launch-scoped** — `loadLayeredEnv` reads only the invocation directory and Harness home once; it does not search parents or follow a workspace selected later. `loadEnv` remains the one-directory helper for non-product bins. - **A user patch replaces the whole matched config** — an id-targeted patch does not deep-merge, so a profile override restates the bundle fields it keeps. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is working context for maintainers: open design questions and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked Agent Notes. + +#### Open: config dump stability + +`renderConfigDump` output is a loadable YAML document whose `# ==` provenance comments and `!!js`-verbatim rendering serve the `--dump-config` diagnostic. Nothing promises byte stability across package versions; decide whether the dump becomes a serialization contract before anything consumes it programmatically. + +
diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index c8396625f2..3a3ef6c821 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -1,60 +1,153 @@ -# `@deepseek-ai/dsh-app-boot` +--- +description: "dsh profile 与临时 Python SDK 运行时的共享 Loader 启动支持:环境层、patch、诊断与配置预览。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-app-boot [English](README.md) | 中文 -供 app bin([`dsh`](../../../apps/cli/README.md) 与 [`dsh-acp-demo`](../../examples/acp-demo/README.md))共用的启动粘合层:每个 bin 都是在这些辅助函数之上构建的精简自执行组合,并以自身诊断前缀参数化。这样,Loader 故障行为只由一处负责,不会在已发布产物之间逐渐分化。 +## 概述 -| 导出 | 职责 | +`dsh-app-boot` 是 `dsh` profile(包括 Python 运行时 wheel 所打包的 CLI)背后的共享 Loader 启动库。它加载环境层、组合 profile bundle 与 patch、启动每个插件,再返回运行中的应用,或指出失败插件与原因。产品应用使用 `dsh` launcher 而不发布单独 bin;直接配置 helper 只保留给低层嵌入方与测试。你还可以在启动前预览生效配置,按 profile 选择实时或仅启动时应用 patch,并让持有终端的应用在致命退出前恢复终端。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +用此包启动应用是一个小而显式的入口:你给它一个配置文件,它运行整个启动过程。本节说明你能做什么、能得到什么;每个结果背后的 helper 调用记录在下方可折叠的实现章节中。 + +### 何时使用 + +在实现共享 `dsh` launcher 或嵌入其低层启动 helper 时使用它。产品功能应放入 profile bundle,而不是新增应用 bin;只向已运行应用添加插件的代码直接挂载插件即可。 + +### 启动应用 + +你把配置文件交给入口,进程就会启动整个应用:加载环境层、应用 patch 与 profile、启动每个插件,并在应用运行后返回。在回放模式下,它会启动同级的 `cordis.snapshot.yml` 替代文件,使已记录的会话能够原样复现。最小的入口只需两次调用: + +```text +installFailLoud('dsh') +const ctx = await boot('dsh', resolveConfigPath(argv[2], process.env.DSH_SNAPSHOT)) +``` + +有了这个入口,成功就是每个插件都已激活的运行中应用;失败绝不会悄无声息——一行带标签的信息点名失败的插件与阶段,进程以非零码退出。错误上报前会先拆卸应用上下文,因此不会留下半启动的残留。 + + +### Profile + +profile 是同一套 dsh 安装提供不同应用界面的方式:`web`、`headless`、`acp`、`sdk` 与 `sdk-minimal` 从同一 launcher 启动不同组合。profile 位于 `$DSH_HOME/profiles/`,由可安装 bundle、自身 `cordis.patch.yml` 与 `patchReload: live | startup` 组成;自定义 profile 省略 reload 策略时保留历史 `live` 默认值。随产品交付的 `web` 模板实时重载,其他随附模板只在启动时应用 patch。`sdk-minimal` 只列出自身的独立 bundle,其他模板保留 base 加模式 bundle 的栈。`dsh plugin` 创建自定义 profile;缺失 bundle 或未声明 patch 的 bundle 会让启动明确失败。 + +你的机器本地偏好同样位于 harness home 中: + +- **`.env`**——你的普通环境层:调用目录的文件优先于 harness home 的文件,两者都低于继承环境。决定进程如何启动的变量(`PATH`、代理、`DSH_*`、`XDG_*` 等)会被文件拒绝:请改为导出。对于只想加载某个目录 `.env` 的非产品 bin,文件缺失不影响启动,文件无法加载时输出一行带标签的警告。 +- **`cordis.patch.yml`**——你的 tweak 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):替换某个条目的整个 config(重述你要保留的字段)、插入新条目,或在启动时插值 `!!js` 表达式。patch 指定的条目不存在时输出 stderr 警告;空文件或仅含注释的文件会导致启动失败——如需禁用该层,请改用 `[]`。 + +带 `patchReload: live` 的 profile 会监视两份用户 patch 文件:有效编辑无需重启即可重新组合,被拒绝的编辑则让最后一个可用应用继续运行。`startup` profile 既不安装这些监视器,也不安装 launcher 的仅监视 HMR 回退。 + +### 预览生效配置 + +启动前,你可以打印应用将挂载的确切配置:dump 会以 `!!js` 表达式原样展示组合后的条目列表,并按注释分组标明每个源文件及其 patch 层,输出是一份可加载的 YAML 文档。未匹配到任何行的 patch 会连同其层标签一起报告;配置缺失、无法解析或字段无效都会使 dump 失败。 + +### 启动失败时你会看到什么 + +启动失败是一行带标签的信息加非零退出码——绝不是静默卡死或原始堆栈倾倒。信息会点名失败的插件;抛错的插件保留原始错误,从未启动的条目会连同它等待的服务一起报告。 + +如果你的应用持有终端,它可以在进程退出前把终端交还,你的 shell 绝不会残留在 raw 模式。交还过程有界:卡住的清理只会延迟致命退出,而不会取消它。 + +### 告诉 agent harness 所在位置 + +当你的应用启动模型驱动的 agent 时,你可以告诉 agent DSH 实现代码 checkout 的位置:它得知该路径,也知道不得据此推断工作目录——它应使用 `pwd`。这条指示在系统提示词靠前位置出现一次。没有系统提示词服务的应用会跳过;开发环境中,重新加载系统提示词后它会消失,直至下次启动。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释上述结果如何实现,并指出实现它们的代码位置;这里的内容面向开发者,使用本包并不需要。 + +### 设计说明 + +- **与渠道无关的库。** 此包不包含 loader 钩子,也不提供开发模式接口;[`dsh` 应用](../../../apps/cli/README.zh.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper,构建后的消费方则使用普通 Node 包解析。 +- **两个 Loader builtin。** `mountRootInclude` 把 `cordis:include` 与 `cordis:group` 注册为 Loader builtin:group 行能把一个提供方与它的消费方放进同一个 `isolate` realm,而位于本工作区之外的 agent preset 无法按名称解析 `@deepseek-ai/cordis-plugin-group`。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析。 +- **Profile 模块后备机制。** 裸插件 specifier 由 Loader 从配置目录解析。普通 Node 会为安装依赖闭包中的每个包维护一个符号链接。打包可执行文件无法让操作系统符号链接进入 pkg 的 `/snapshot` 树,因此会按 Node ESM 条件读取已安装包的 export map,并写入重新导出虚拟模块 URL 的真实代理包。缺失 export 保持不可用,错误 export map 会让启动失败,跨进程 writer lock 则会在不暴露部分代理的情况下替换陈旧条目。所选外部 bundle 若不在安装闭包中,则会获得 profile 本地的 `.dsh-module-fallback` 链接;已有 pnpm 条目优先,后续闭包发现会排除投影链接,清理也只删除 dsh 自有链接。 +- **单一 rejection 检查点。** `assertEntriesActivated` 把折入启动诊断的确切原因保持到下一个进程级 rejection 检查点可见,使 `installFailLoud` 能合并 Loader 的重复通知,而所有无关的未处理 rejection 仍然致命。 +- **两阶段失败标签。** `boot()` 区分 `host preparation failed`(`prepare` 在任何配置树条目挂载前抛出)与 `plugin tree failed to load`(此后的一切失败),并追加最深层插件错误的堆栈,使启动诊断保留原始激活错误,而不只是包装链。 + +### Helper 行为 + +每个导出各负责启动的一个阶段:配置解析与快照回放、分层环境加载、明确报错的保护机制、激活审计、patch 解析、根 include 挂载、配置 dump 渲染、活动 patch 监视、profile 组合,以及 harness 源码段落。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts) 与 [`src/profile.ts`](src/profile.ts)。 + +### 源码地图 + +| 文件 | 职责 | |---|---| -| `resolveConfigPath(path, snapshotMode, cwd?)` | 生成绝对配置路径;当 `snapshotMode === 'replay'` 时,把 basename 为 `cordis.yml`/`.yaml` 的文件替换为同级 `cordis.snapshot.yml` | -| `loadEnv(binName, dir?, warn?)` | 加载已被 git 忽略的 `.env`(Node `process.loadEnvFile`);文件不存在不影响启动,文件无法加载时输出一行带标签的警告(默认写入 stderr) | -| `loadLayeredEnv(binName, cwd?, warn?)` | 构建产品 CLI(命令行界面)冻结的「继承环境 > 项目 `.env` > 用户 `.env`」快照,拒绝文件中的 bootstrap-only 变量,并在不替换继承值的前提下物化其余文件值 | -| `installFailLoud(binName, proc?, release?)` | 将启动期或后续未处理的 Loader 拒绝转换为一行带标签的 stderr 消息并执行 `exit(1)`;两者之间会等待可选的 `release` 清理钩子(以 `FAIL_LOUD_RELEASE_TIMEOUT_MS` 为上限),使持有终端的界面能在退出前恢复终端;返回卸载函数 | -| `FAIL_LOUD_RELEASE_TIMEOUT_MS` | `installFailLoud` 等待其 `release` 回调的时长;卡死的 disposer 只会延迟致命退出,而不会取消它 | -| `assertEntriesLoaded(ctx, binName)` | 树结算后,如果其中存在已启用但没有 fiber 的条目,则抛出异常,并以 Cordis 启动故障的形式报告每个未解析插件的名称 | -| `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | -| `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | -| `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 | -| `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步 disposer | -| `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 | -| `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | 使用 include 自己的解析器和补丁算法(`entryListSchema`/`applyEntryPatches`)离线合成基础配置与带标签的覆盖层,使结果与 `boot()` 挂载的内容一致,再渲染为 YAML,并原样保留 `!!js` 表达式;每段来源于同一文件且由相同补丁层修改的连续行之前都有一条 `# ==` 注释,标明该文件和这些补丁层,输出仍是一份可加载的文档;未匹配到行的补丁连同其层标签交给 `warn`(默认:一行 stderr),读取、解析或字段验证失败则抛出 | -| `addHarnessSourceSection(ctx, sourceRoot)` | 添加全局 `harness:source` 提示词段落(顺序紧随 harness 身份、位于 persona 之前),告知 agent(智能体)DSH 实现代码 checkout 的磁盘路径,同时提醒它不得据此推断当前工作目录,而应使用 `pwd`;如果已启动树没有此项服务,则不执行操作并返回 `undefined`。这里的服务是 `systemPrompt`;该段落注册到它的 fiber,因此开发环境 HMR 重新加载系统提示词后,它会消失直至下次启动 | -| `HARNESS_SOURCE_SECTION` | `'harness:source'` 段落名称,供 `addHarnessSourceSection` 注册使用 | +| [`src/index.ts`](src/index.ts) | 启动 helper:配置解析、环境加载、会明确报错的保护机制、激活审计、patch 解析、配置 dump、harness 源码段落 | +| [`src/profile.ts`](src/profile.ts) | profile 发现、初始化、组合包解析、模块后备机制 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;边界与回放测试覆盖其协议映射) | -Loader 结算会在导入或生命周期失败时返回拒绝结果,并携带失败的配置项与阶段;`boot()` 会 dispose 部分构造的上下文,并用 bin 名称包装该失败。结算后遗留的配置项由独立审计处理:`assertEntriesLoaded` 将已启用却没有 fiber 的配置项转换为 rejection 并列出每个未解析插件;`assertEntriesActivated` 会显式等待每个失败的 fiber,把原始错误堆栈写入启动 rejection,并列出每个等待中配置项尚未解析的服务。抛出错误前,审计会通过一个进程级检查点标记这些 rejection 的确切原因,从而让 `installFailLoud` 将 Loader 的重复通知合并为一次,而所有无关的未处理 rejection 仍然致命。 +
-Loader 并发挂载各个条目,因此当其他环节失败时,某个界面可能已经持有终端:此时不经过整棵树自身的拆卸就退出,会把 raw 模式、bracketed paste 和键盘协议残留在用户的 shell 上,而尚未返回的终端查询响应会在下一个提示符处显示为字面文本。配置树失败会经 `boot()` 结算:它先 dispose 部分构建的上下文(从而执行该界面自身的 shutdown),再抛出带标签的 rejection。对于 `boot()` 看不到的 rejection(插件游离的异步工作在挂载期间或挂载完成后失败),持有终端的 bin 会传入 `release`,在提交退出前 dispose 整棵树;`dsh` 在 `boot()` 的 `prepare` 回调中捕获根上下文,而不是取其返回值,使该回调覆盖整个挂载窗口。release 执行期间,处理函数保持注册并处于锁定状态:被报告的始终是第一个 rejection,后续拒绝(包括拆卸自身产生的拒绝)会被忽略,而不会变成未捕获错误、在拆卸中途杀死进程。 +----- -`cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 harness home 下的 agent preset——能够使用 group 行的原因。 + +## 进一步探索 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选对等依赖(peer dependency) `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`pnpm dsh` 源码路径还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +当包级约定不够用时阅读以下页面。它们从共享启动机制逐步进入组合模型及其背后的决策证据。 -此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 +- [Cordis 入门](../../../docs/cordis-primer.zh.md)——Loader、`!!js` 配置表达式,以及 include/group 语义。 +- [dsh 应用](../../../apps/cli/README.zh.md)——消费这些 helper 的 `dsh` bin。 +- [dsh-cmdline](../cmdline/README.zh.md)——各 bin 使用的启动器到应用命令行交接。 +- [Profile 组合包](../../bundle/README.zh.md)——组合进 `dsh --profile` 的可安装 patch 层。 +- [dsh-home-paths](../../util/home-paths/README.zh.md)——harness home 解析器(`resolveDshHome`)。 +- [配置来源归属](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.zh.md)——被发现的文件为何不得决定 bootstrap 行为。 +- [Profile 插件组合包](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包组合设计。 -## Profiles - -profile 是位于 `$DSH_HOME/profiles/` 下的目录(harness home 由 [`resolveDshHome`](../../util/home-paths/README.md) 解析:先取 `$DSH_HOME`,否则取 `~/.dsh`),其中包含一个 `package.json`(树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和用户自己的 `cordis.patch.yml`。组合包是在 manifest 中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;`loadProfile` 以双锚点解析每个 `dsh.profile.bundles` 名称(先从 dsh 安装目录,再从 profile 目录),列出的包若没有组合包声明则明确报错。`composeEntries` 通过 include 自己的 `applyEntryPatches` 在空条目列表之上应用各 patch 层,因此组合、标志推导和配置 dump 绝不会与实际启动内容发生偏离。`healProfilesModuleFallback` 维护扁平的 `$DSH_HOME/profiles/node_modules` 目录(安装目录的应用与各组合包依赖的每个包对应一个符号链接),使任意 profile 中的裸插件名都能经 Node 常规的逐级向上查找解析,而无需由 pnpm 管理随安装内置的包。`PROFILE_TEMPLATES`(`web`、`headless`)在首次使用时自动初始化;其他名称在 `initProfile` 创建之前都会明确报错(即 `dsh plugin` 路径)。`loadProfile` 会将与安装自有组合包元组完全一致的列表规范化为随发行版交付的模板,同时保留 manifest 中其他所有字段;一旦条目有任何额外、缺失或重排,该列表就归用户所有并保持不变。 - -用户级的机器本地偏好同样位于 harness home 中: - -- **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 -- **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 - -每次 profile 启动都由 `watchUserPatches` 持续应用 `cordis.patch.yml` 的变更(一次性 surface 经由有界关闭 dispose 监视器)。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +----- + ## 模型体验 -模型通过此包加载的插件树间接受到影响;该树决定最终应用中的提示词、schema、消息和模型适配器。唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 +模型通过此包加载的插件树间接受影响——只有该树贡献模型上下文;唯一贡献模型可见文本的导出 `addHarnessSourceSection`,也只有在消费方启动后调用它时才会产生影响。 #### KV Cache 影响 -`boot()` 不会直接使缓存失效;消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效。请求前缀的其他任何变化均由相应的具名消费方负责。 +启动本身不会使请求前缀中的任何内容失效。消费方调用 `addHarnessSourceSection` 时,会在系统提示词靠前位置、逐请求内容之前添加一行短文本,因此不会使跨轮次缓存失效;请求前缀的其他任何变化均由相应的具名消费方负责。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **裸包 specifier 依赖 Loader 内部机制**:生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 -- **快照回放替换仅识别特定 basename**:只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 -- **环境发现以启动为界**:`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 -- **用户 patch 会替换匹配到的整个配置**:按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 + + + +这些限制说明此启动库在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。 + +- **裸包 specifier 依赖 Loader 内部机制**——生产 bin 需要 Loader 的可选原生辅助组件;没有该辅助组件的进程内调用方必须使用可解析的相对/file specifier,或提供自己的模块解析钩子。 +- **快照回放替换仅识别特定 basename**——只有以 `cordis.yml` 或 `cordis.yaml` 结尾的配置会映射到同级 `cordis.snapshot.yml`;自定义配置名称需要调用方自行选择。 +- **环境发现以启动为界**——`loadLayeredEnv` 只读取一次调用目录与 harness home 中的 `.env`;它不搜索父目录,也不跟随之后选择的 workspace。`loadEnv` 仍是非产品 bin 使用的单目录 helper。 +- **用户 patch 会替换匹配到的整个配置**——按 id 定位的 patch 不做深度合并,因此 profile 覆盖必须重述需要保留的组合包字段。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +本开发备注是维护者的工作上下文:开放设计问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文、包代码和相关 Agent Note 为准。 + +#### 待定:配置 dump 稳定性 + +`renderConfigDump` 的输出是一份可加载的 YAML 文档,其 `# ==` 来源注释与 `!!js` 原样渲染服务于 `--dump-config` 诊断。任何内容都不承诺跨包版本的字节稳定性;在程序化消费该输出之前,请决定 dump 是否成为序列化约定。 + +
diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index a31983a599..d33cae8870 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -32,7 +32,9 @@ ], "license": "MIT", "dependencies": { - "js-yaml": "^4.2.0" + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "js-yaml": "^4.2.0", + "resolve.exports": "^2.0.3" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-group": "workspace:^", diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index 9be66947bb..89f92762bb 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -1,5 +1,5 @@ /** - * Shared boot glue for the app bins (`dsh`, `dsh-acp-demo`): load the gitignored + * Shared boot glue for `dsh` profiles, including the CLI packaged by the Python runtime wheel: load the gitignored * `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), load the * optional user patch layers from the Harness home (`~/.dsh`), expose its path resolver to * config expressions, and drive the Cordis Loader against a leaf `cordis.yml` until the tree settles. @@ -18,8 +18,7 @@ import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-home-paths' import { createLaunchEnvironmentSnapshot, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import type {} from '@deepseek-ai/cordis-plugin-hmr' -// Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' declare module '@deepseek-ai/cordis' { interface Context { @@ -31,6 +30,7 @@ declare module '@deepseek-ai/cordis' { export { composeEntries, DEFAULT_PROFILE_BUNDLES, + DEFAULT_PROFILE_PATCH_RELOAD, healProfilesModuleFallback, initProfile, loadProfile, @@ -47,6 +47,9 @@ export { type Profile, type ProfileLayer, type ProfileManifest, + type ProfileModuleFallbackOptions, + type ProfilePatchReload, + type ProfileTemplate, } from './profile.ts' /** @@ -100,11 +103,11 @@ const BOOTSTRAP_NAMES = new Set([ 'PERL5OPT', 'PERL5LIB', 'PYTHONSTARTUP', 'PYTHONPATH', 'RUBYOPT', 'RUBYLIB', 'JAVA_TOOL_OPTIONS', '_JAVA_OPTIONS', 'JDK_JAVA_OPTIONS', 'PYTHONHOME', - // Version-control command hooks and config redirects. + // Version-control hooks, config redirects, and ambient command selectors. 'GIT_SSH', 'GIT_SSH_COMMAND', 'GIT_EXTERNAL_DIFF', 'GIT_PAGER', 'GIT_EDITOR', 'GIT_ASKPASS', 'SSH_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_SYSTEM', 'GIT_CONFIG_COUNT', - 'EDITOR', 'VISUAL', 'PAGER', + 'EDITOR', 'VISUAL', 'PAGER', 'BROWSER', // Network reach and trust. 'DEEPSEEK_BASE_URL', 'DEEPSEEK_SEARCH_BASE_URL', 'SSL_CERT_FILE', 'SSL_CERT_DIR', @@ -239,9 +242,8 @@ export async function watchUserPatches( const entry = bootstrapIncludes.get(ctx) if (entry === undefined) throw new Error(`${binName}: user patch-layer watching requires the root Include entry`) const register = hmr.registerConfig(filename, async () => { - // Re-read the include's non-patch options per refresh: a writer that - // updates the root Include's other options between refreshes (none exists - // today) must not have them silently reverted by a user-layer reload. + // Re-read the include's non-patch options per refresh so a writer that + // updates another option between refreshes is not silently reverted. const { patches: _previousPatches, ...includeConfig } = entry.options.config as Include.Config const userPatches = loadOptionalPatches(binName, filename) ?? [] const patches = compose(userPatches) @@ -304,6 +306,19 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } return parsePatchList(binName, file, content, 'overlay') } + +/** Resolve relative plugin paths in one patch file's `insert` rows without changing assertion names. */ +function anchorInsertedPluginNames(patches: PatchOptions[], file: string): PatchOptions[] { + const base = dirname(resolve(file)) + const visit = (entry: EntryOptions): void => { + if (typeof entry.name === 'string' && (entry.name.startsWith('./') || entry.name.startsWith('../'))) { + entry.name = pathToFileURL(resolve(base, entry.name)).href + } + if (entry.group && Array.isArray(entry.config)) entry.config.forEach(visit) + } + for (const patch of patches) patch.insert?.forEach(visit) + return patches +} /** * Parse one loader patch list: a top-level YAML array of * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and @@ -334,7 +349,7 @@ function parsePatchList( throw new Error(`${binName}: ${label} entry ${index + 1} in ${file} must be a mapping (a loader patch entry)`) } }) - return parsed as PatchOptions[] + return anchorInsertedPluginNames(parsed as PatchOptions[], file) } /** One overlay patch list with the source label printed in dump comments. */ @@ -796,7 +811,9 @@ export async function boot( // original activation error instead of only the wrap chain. let deepest: unknown = cause while (deepest instanceof Error && deepest.cause !== undefined) deepest = deepest.cause - const stack = deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' + const stack = deepest instanceof AggregateError + ? `\n${deepest.stack ?? deepest.message}\n${deepest.errors.map(formatActivationError).join('\n')}` + : deepest instanceof Error && deepest !== cause ? `\n${deepest.stack ?? deepest.message}` : '' throw new Error(`${binName}: ${stage}: ${detail}${stack}`, { cause }) } } @@ -809,8 +826,8 @@ export const HARNESS_SOURCE_SECTION = 'harness:source' * explicitly distinguishing it from the task workspace and current working * directory. The self-referential `dsh-tool-cordis` toolset reads and edits this * checkout. Call once on the settled boot context ({@link boot}); the section - * orders just after the harness identity opener (`-100`) and before the deployment - * persona (`0`). A booted tree with no `systemPrompt` service has no prompt to + * uses the shared first-party placement just after the harness identity opener + * and before the deployment persona. A booted tree with no `systemPrompt` service has no prompt to * augment, so this is then a no-op that returns `undefined`. The section is * registered against the `systemPrompt` service's fiber, so a dev HMR reload of * that plugin drops it until the next boot. @@ -823,7 +840,7 @@ export function addHarnessSourceSection(ctx: Context, sourceRoot: string): (() = if (systemPrompt === undefined) return undefined return systemPrompt.section({ name: HARNESS_SOURCE_SECTION, - order: -99, + order: FIRST_PARTY_SECTION_ORDER.HARNESS_SOURCE, text: `The DeepSeek Harness implementation checkout is at ${sourceRoot}. The checkout location and current working directory are separate values and may differ; never infer the working directory from this path. Use pwd to determine the current working directory. Use this checkout only to inspect or extend DSH itself.`, }) } diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 8f982bed80..52e8bcfafc 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -14,22 +14,27 @@ * * Module resolution is two-anchor by construction: a bundle name resolves * first from the dsh installation (the launcher's own package), then from the - * profile directory. The Loader's `baseUrl` is the profile directory, whose - * `node_modules` pnpm manages for out-of-tree plugins, while the maintained - * flat fallback directory `$DSH_HOME/profiles/node_modules` (one symlink per - * package the installation's app and bundles depend on) makes every in-box - * plugin Node-resolvable from any profile through the ordinary parent-walk. + * profile directory. Pnpm-managed entries in the profile's `node_modules` + * resolve first. Dsh-owned links add packages carried only by selected + * bundles, while `$DSH_HOME/profiles/node_modules` supplies the installation + * dependency closure through Node's ordinary parent-walk. Plain Node uses + * symlinks for that shared fallback; packaged executables use ESM proxies so + * external plugins retain the installation's module instances. * @module @deepseek-ai/dsh-app-boot/profile */ import { createRequire } from 'node:module' import { - existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync, unlinkSync, writeFileSync, + existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, rmSync, statSync, + symlinkSync, unlinkSync, writeFileSync, } from 'node:fs' -import { basename, dirname, join } from 'node:path' +import { basename, dirname, join, relative, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-home-paths' +import { resolve as resolvePackage, type Package as ResolvePackageManifest } from 'resolve.exports' import { loadOverlayPatches } from './index.ts' /** Directory under the Harness home holding every profile. */ @@ -38,6 +43,9 @@ export const PROFILES_DIR = 'profiles' /** The user patch layer inside a profile directory (hot-reloaded on long-lived surfaces). */ export const PROFILE_PATCH_FILENAME = 'cordis.patch.yml' +/** Profile-private package links projected into its pnpm-managed node_modules. */ +const PROFILE_MODULE_FALLBACK_DIR = '.dsh-module-fallback' + /** The bundle half of the `dsh` manifest section: what a bundle package exports. */ export interface DshBundleManifest { /** The patch layer this bundle exports, relative to its package root. */ @@ -48,6 +56,19 @@ export interface DshBundleManifest { export interface DshProfileManifest { /** Ordered bundle layer list (package names). */ bundles?: string[] + /** Whether user patch files reload while this profile remains active. */ + patchReload?: ProfilePatchReload +} + +/** User patch-file lifecycle selected by a profile. */ +export type ProfilePatchReload = 'live' | 'startup' + +/** Installation-owned defaults used when a shipped profile is first opened. */ +export interface ProfileTemplate { + /** Ordered bundle layer list. */ + bundles: readonly string[] + /** User patch-file lifecycle for the generated profile. */ + patchReload: ProfilePatchReload } /** @@ -93,6 +114,8 @@ export interface Profile { patchPath: string /** The profile's own patches; empty when the file is absent. */ patches: PatchOptions[] + /** Whether the launcher watches user patch files after boot. */ + patchReload: ProfilePatchReload } /** @@ -111,9 +134,27 @@ export function resolveProfileDir(name: string, home: string = resolveDshHome()) } /** The shipped profile templates auto-initialized on first use, by name. */ -export const PROFILE_TEMPLATES: Record = { - web: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], - headless: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'], +export const PROFILE_TEMPLATES: Record = { + acp: { + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'], + patchReload: 'startup', + }, + web: { + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app'], + patchReload: 'live', + }, + headless: { + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'], + patchReload: 'startup', + }, + sdk: { + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'], + patchReload: 'startup', + }, + 'sdk-minimal': { + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }, } /** Installation-owned bundle tuples normalized to the shipped template. */ @@ -124,6 +165,9 @@ const INSTALLATION_OWNED_PROFILE_TUPLES: Record = { /** The bundle list a `dsh plugin` init uses for a name with no shipped template. */ export const DEFAULT_PROFILE_BUNDLES: readonly string[] = ['@deepseek-ai/dsh-base'] +/** Custom profiles retain the historical live patch-file behavior. */ +export const DEFAULT_PROFILE_PATCH_RELOAD: ProfilePatchReload = 'live' + const PROFILE_PATCH_TEMPLATE = `# Your patch layer for this dsh profile, applied after every bundle layer: # a top-level YAML array of loader patch entries (id-targeted config # overrides, disables, and insert lists; \`!!js\` expressions allowed). @@ -148,8 +192,13 @@ autoInstallPeers: false * so re-running is a no-op on an initialized profile. * @param dir - the profile directory from {@link resolveProfileDir}. * @param bundles - the initial `dsh.profile.bundles` layer list. + * @param patchReload - user patch-file lifecycle; custom profiles default to live reload. */ -export function initProfile(dir: string, bundles: readonly string[]): void { +export function initProfile( + dir: string, + bundles: readonly string[], + patchReload: ProfilePatchReload = DEFAULT_PROFILE_PATCH_RELOAD, +): void { mkdirSync(dir, { recursive: true }) const manifestPath = join(dir, 'package.json') if (!existsSync(manifestPath)) { @@ -157,7 +206,7 @@ export function initProfile(dir: string, bundles: readonly string[]): void { name: `dsh-profile-${basename(dir)}`, private: true, dependencies: {}, - dsh: { profile: { bundles: [...bundles] } }, + dsh: { profile: { bundles: [...bundles], patchReload } }, } writeFileSync(manifestPath, JSON.stringify(manifest, undefined, 2) + '\n') } @@ -167,7 +216,16 @@ export function initProfile(dir: string, bundles: readonly string[]): void { if (!existsSync(workspacePath)) writeFileSync(workspacePath, PROFILE_PNPM_WORKSPACE) } -/** Ensure `link` is a symlink to `target`, replacing a wrong or dangling link; a real directory throws. */ +function readModuleProxyRecord(link: string): ModuleProxyRecord | undefined { + try { + return JSON.parse(readFileSync(join(link, 'package.json'), 'utf8')) as ModuleProxyRecord + } catch { + // Missing or invalid metadata is not managed state; callers reject it. + return undefined + } +} + +/** Ensure `link` is a symlink to `target`, replacing a wrong link or a dsh-managed packaged proxy. */ function ensureSymlink(link: string, target: string): void { let stat try { @@ -179,12 +237,19 @@ function ensureSymlink(link: string, target: string): void { } if (stat !== undefined) { if (!stat.isSymbolicLink()) { - throw new Error(`dsh: ${link} exists and is not a symlink; remove it so dsh can manage the installation fallback`) + const existing = stat.isDirectory() ? readModuleProxyRecord(link) : undefined + if (existing?.dsh?.moduleFallback?.targets === undefined) { + throw new Error(`dsh: ${link} exists and is not a symlink or dsh-managed module proxy; remove it so dsh can manage the installation fallback`) + } + rmSync(link, { recursive: true }) + stat = undefined + } + if (stat !== undefined) { + if (symlinkPointsTo(link, target)) return + // unlink deletes the reparse point itself on Windows too; rmSync treats a + // junction as a directory and throws EISDIR unless recursive. + unlinkSync(link) } - if (readlinkSync(link) === target) return - // unlink deletes the reparse point itself on Windows too; rmSync treats a - // junction as a directory and throws EISDIR unless recursive. - unlinkSync(link) } try { symlinkSync(target, link, 'junction') @@ -195,36 +260,244 @@ function ensureSymlink(link: string, target: string): void { // staged deterministically from the public API. /* v8 ignore next 4 */ if ((error as NodeJS.ErrnoException).code !== 'EEXIST' - || !lstatSync(link).isSymbolicLink() || readlinkSync(link) !== target) { + || !lstatSync(link).isSymbolicLink() || !symlinkPointsTo(link, target)) { throw error } } } +/** Resolve a link target without following the final path component. */ +function canonicalLinkPath(path: string): string | undefined { + try { + return join(realpathSync.native(dirname(path)), basename(path)) + } catch (error) { + // A missing parent means the candidate cannot identify an existing owned link. + /* v8 ignore next 2 -- a non-ENOENT realpath failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return undefined + /* v8 ignore next -- see the host-filesystem exception above */ + throw error + } +} + +/** Return whether a symlink or junction points at the same path as `target`. */ +function symlinkPointsTo(link: string, target: string): boolean { + const actual = resolve(dirname(link), readlinkSync(link)) + const canonicalActual = canonicalLinkPath(actual) + const canonicalTarget = canonicalLinkPath(resolve(target)) + return canonicalActual !== undefined && canonicalActual === canonicalTarget +} + +/** Add one profile-owned fallback link without replacing a pnpm-managed entry. */ +function ensureProfileSymlink(link: string, target: string): void { + try { + lstatSync(link) + return + } catch (error) { + /* v8 ignore next -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + ensureSymlink(link, target) +} + +/** Package names represented by owned symlinks below one fallback node_modules. */ +function ownedPackageNames(modulesDir: string): string[] { + return readdirSync(modulesDir, { withFileTypes: true }).flatMap((entry) => { + if (entry.name.startsWith('@') && entry.isDirectory()) { + return readdirSync(join(modulesDir, entry.name), { withFileTypes: true }) + .filter(child => child.isSymbolicLink()) + .map(child => `${entry.name}/${child.name}`) + } + return entry.isSymbolicLink() ? [entry.name] : [] + }) +} + +/** Remove an obsolete owned target and its profile projection when still connected. */ +function removeProfileSymlink(profileModulesDir: string, ownedModulesDir: string, packageName: string): void { + const ownedLink = join(ownedModulesDir, packageName) + const profileLink = join(profileModulesDir, packageName) + try { + if (lstatSync(profileLink).isSymbolicLink() && symlinkPointsTo(profileLink, ownedLink)) unlinkSync(profileLink) + } catch (error) { + /* v8 ignore next -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } + try { + unlinkSync(ownedLink) + } catch (error) { + /* v8 ignore next -- concurrent identical cleanup may remove the link first */ + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error + } +} + +interface ModuleProxyManifest { + name: string + version: string + private: true + type: 'module' + exports: Record + dsh: { moduleFallback: { targets: Record } } +} + +interface ModuleProxyRecord { + version?: unknown + dsh?: { moduleFallback?: { targets?: unknown } } +} + +/** Return whether the process reads application modules from pkg's virtual filesystem. */ +function isPackagedExecutable(): boolean { + return (process as NodeJS.Process & { pkg?: unknown }).pkg !== undefined +} + +/** Resolve one available explicit package export under Node ESM import conditions. */ +function packageEntryFromPackage( + packageName: string, + packageDir: string, + declared: ResolvePackageManifest['exports'], + subpath: string, +): string | undefined { + let candidates: string[] | void + try { + candidates = resolvePackage({ name: packageName, exports: declared }, subpath) + } catch (error) { + if ((error as Error).message.startsWith('No known conditions for ')) return undefined + const specifier = subpath === '.' ? packageName : packageName + subpath.slice(1) + throw new Error(`dsh: cannot resolve ESM export ${specifier} from installed package ${packageName}`, { cause: error }) + } + for (const candidate of candidates ?? []) { + const target = candidate + const entry = resolve(packageDir, target) + const relativeEntry = relative(packageDir, entry) + if (!target.startsWith('./') || /^\.\.(?:[\\/]|$)/u.test(relativeEntry)) { + throw new Error(`dsh: installed package ${packageName} export ${subpath} resolves outside its package: ${target}`) + } + if (existsSync(entry) && statSync(entry).isFile()) return pathToFileURL(entry).href + } + return undefined +} + +/** Resolve every explicit ESM runtime export that an out-of-tree plugin can import. */ +function packageProxySource( + packageName: string, + packageDir: string, +): { version: string; targets: Record } { + const manifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as { + bin?: unknown + exports?: unknown + main?: unknown + types?: unknown + typings?: unknown + version?: unknown + } + if (typeof manifest.version !== 'string' || manifest.version.length === 0) { + throw new Error(`dsh: installed package ${packageName} must declare a non-empty version`) + } + const declared = manifest.exports + if (declared === undefined) { + const main = typeof manifest.main === 'string' && manifest.main.length > 0 ? manifest.main : undefined + const entry = join(packageDir, main ?? 'index') + try { + const resolved = createRequire(join(packageDir, 'package.json')).resolve(entry) + return { version: manifest.version, targets: { '.': pathToFileURL(resolved).href } } + } catch (error) { + if (main === undefined + && (manifest.bin !== undefined || manifest.types !== undefined || manifest.typings !== undefined)) { + return { version: manifest.version, targets: {} } + } + throw new Error(`dsh: installed package ${packageName} main entry is missing at ${entry}`, { cause: error }) + } + } + const subpaths = declared !== null && typeof declared === 'object' && !Array.isArray(declared) + && Object.keys(declared).some(key => key.startsWith('.')) + ? Object.keys(declared).filter(key => key === '.' || ( + key.startsWith('./') && !key.includes('*') && !key.endsWith('/') && key !== './package.json' + )) + : ['.'] + const targets: Record = {} + for (const subpath of subpaths) { + const target = packageEntryFromPackage( + packageName, + packageDir, + declared as ResolvePackageManifest['exports'], + subpath, + ) + if (target !== undefined) targets[subpath] = target + } + return { version: manifest.version, targets } +} + /** - * Maintain the flat module fallback `$DSH_HOME/profiles/node_modules`: one - * symlink per package in the dsh app's resolvable dependency CLOSURE (BFS - * over `dependencies` from the app manifest), each resolved from its own - * real location. Node's parent-directory walk from any profile finds this - * directory after the profile's own `node_modules`, so every in-box plugin - * resolves without pnpm ever managing it — the exact "bundles come from the - * installation" contract. The closure (not just direct dependencies) is - * required for out-of-tree plugins: their peer dependencies name Service - * Definition packages (`dsh-compaction`, `dsh-invariants`, ...) that the app - * reaches only through its Service Provider packages. Symlinked packages - * resolve their own dependencies from their real directories (Node's default - * symlink-following), so each package needs only its one flat link. - * Idempotent: correct links are kept and moved installations are - * re-pointed; a stale link to a vanished package stays until its name is - * reused (dangling links are invisible to resolution). - * @param installAnchor - absolute path of the dsh app's package.json. - * @param home - the Harness home; defaults to {@link resolveDshHome}. + * Materialize a real package proxy whose exports retain pkg's virtual module + * URL. Files outside the executable cannot traverse a symlink into + * `/snapshot`, while an ESM re-export can import that URL and preserves the + * executable's single module instance for out-of-tree plugin peers. */ -export function healProfilesModuleFallback(installAnchor: string, home: string = resolveDshHome()): void { - const profilesDir = join(home, PROFILES_DIR) - const modulesDir = join(profilesDir, 'node_modules') - mkdirSync(modulesDir, { recursive: true }) - const appManifest = JSON.parse(readFileSync(installAnchor, 'utf8')) as ProfileManifest +function ensureModuleProxy( + link: string, + packageName: string, + version: string, + targets: Record, +): void { + const proxyExports = Object.fromEntries( + Object.keys(targets).map((subpath, index) => [subpath, `./entry-${index}.js`]), + ) + const manifest: ModuleProxyManifest = { + name: packageName, + version, + private: true, + type: 'module', + exports: proxyExports, + dsh: { moduleFallback: { targets } }, + } + let stat + try { + stat = lstatSync(link) + } catch { + stat = undefined + } + if (stat?.isSymbolicLink()) { + unlinkSync(link) + stat = undefined + } + if (stat !== undefined) { + const existing = readModuleProxyRecord(link) + if (existing?.dsh?.moduleFallback?.targets === undefined) { + throw new Error(`dsh: ${link} exists and is not a dsh-managed module proxy; remove it so dsh can manage the installation fallback`) + } + if (existing.version === version + && JSON.stringify(existing.dsh.moduleFallback.targets) === JSON.stringify(targets) + && Object.keys(targets).every((_, index) => existsSync(join(link, `entry-${index}.js`)))) return + rmSync(link, { recursive: true }) + } + mkdirSync(link, { recursive: true }) + writeFileSync(join(link, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n') + for (const [index, target] of Object.values(targets).entries()) { + const specifier = JSON.stringify(target) + writeFileSync( + join(link, `entry-${index}.js`), + `export * from ${specifier}\nimport * as target from ${specifier}\nexport default target.default\n`, + ) + } +} + +type ModuleFallbackEntry = + | { kind: 'symlink'; packageName: string; packageDir: string } + | { kind: 'proxy'; packageName: string; version: string; targets: Record } + +/** Read one package manifest used while traversing a module-fallback dependency graph. */ +function readModuleFallbackManifest(anchor: string): ProfileManifest { + return JSON.parse(readFileSync(anchor, 'utf8')) as ProfileManifest +} + +/** Return dependency names that may be imported by a loader-visible plugin. */ +function profileDependencyNames(manifest: ProfileManifest): string[] { + return [...Object.keys(manifest.dependencies ?? {}), ...Object.keys(manifest.peerDependencies ?? {})] +} + +/** Resolve the installation generation that every profile must find through the fallback directory. */ +function resolveModuleFallbackEntries( + installAnchor: string, +): { entries: ModuleFallbackEntry[]; packageNames: ReadonlySet } { + const appManifest = readModuleFallbackManifest(installAnchor) const links = new Map() /* v8 ignore next -- a real app manifest always declares its name */ if (appManifest.name !== undefined) links.set(appManifest.name, dirname(installAnchor)) @@ -236,7 +509,7 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = // dsh-compaction, ...) are peers of their implementations, never plain // dependencies, yet out-of-tree plugins import them directly. /* v8 ignore next -- a real app manifest always declares dependencies */ - for (const dep of [...Object.keys(next.manifest.dependencies ?? {}), ...Object.keys(next.manifest.peerDependencies ?? {})]) { + for (const dep of profileDependencyNames(next.manifest)) { if (links.has(dep)) continue const dir = packageDirFromAnchor(next.anchor, dep) // A declared-but-uninstalled dependency cannot be a loader-visible @@ -244,13 +517,162 @@ export function healProfilesModuleFallback(installAnchor: string, home: string = if (dir === undefined) continue links.set(dep, dir) const manifestPath = join(dir, 'package.json') - queue.push({ anchor: manifestPath, manifest: JSON.parse(readFileSync(manifestPath, 'utf8')) as ProfileManifest }) + queue.push({ anchor: manifestPath, manifest: readModuleFallbackManifest(manifestPath) }) } } - for (const [packageName, target] of links) { - const link = join(modulesDir, packageName) + const entries = !isPackagedExecutable() + ? [...links].map(([packageName, packageDir]) => ({ kind: 'symlink' as const, packageName, packageDir })) + : [...links].flatMap(([packageName, packageDir]) => { + const source = packageProxySource(packageName, packageDir) + return Object.keys(source.targets).length === 0 + ? [] + : [{ kind: 'proxy' as const, packageName, version: source.version, targets: source.targets }] + }) + return { entries, packageNames: new Set(links.keys()) } +} + +/** Return whether one existing fallback entry already matches its resolved installation generation. */ +function moduleFallbackEntryCurrent(modulesDir: string, entry: ModuleFallbackEntry): boolean { + const link = join(modulesDir, entry.packageName) + try { + const stat = lstatSync(link) + if (entry.kind === 'symlink') { + return stat.isSymbolicLink() && readlinkSync(link) === entry.packageDir + } + if (!stat.isDirectory()) return false + const existing = readModuleProxyRecord(link) + return existing?.version === entry.version + && JSON.stringify(existing.dsh?.moduleFallback?.targets) === JSON.stringify(entry.targets) + && Object.keys(entry.targets).every((_, index) => existsSync(join(link, `entry-${index}.js`))) + } catch { + return false + } +} + +/** Return whether every required fallback entry is already ready for this installation. */ +function moduleFallbackCurrent(modulesDir: string, entries: readonly ModuleFallbackEntry[]): boolean { + return entries.every(entry => moduleFallbackEntryCurrent(modulesDir, entry)) +} + +/** Inputs for {@link healProfilesModuleFallback}. */ +export interface ProfileModuleFallbackOptions { + /** Absolute package.json path of the running dsh installation. */ + installAnchor: string + /** Loaded profile whose selected bundles may carry profile-local plugins. */ + profile?: Profile + /** Harness home; defaults to {@link resolveDshHome}. */ + home?: string +} + +/** + * Maintain module fallbacks for one profile launch. The shared + * `$DSH_HOME/profiles/node_modules` mirrors the dsh installation dependency + * closure. Plain Node writes symlinks; a packaged executable writes ESM + * proxies under a cross-process lock because operating-system links cannot + * enter pkg's virtual filesystem. Missing packages carried only by selected + * bundles are linked through a profile-owned directory into that profile's + * `node_modules`; pnpm-managed entries remain authoritative, and another + * profile's links cannot change its resolution. + * @param options - installation anchor, optional loaded profile, and Harness home. + * @returns settlement after the shared fallback and profile-local links are current. + */ +export async function healProfilesModuleFallback(options: ProfileModuleFallbackOptions): Promise { + const { installAnchor, profile, home = resolveDshHome() } = options + const profilesDir = join(home, PROFILES_DIR) + const modulesDir = join(profilesDir, 'node_modules') + mkdirSync(modulesDir, { recursive: true }) + const { entries, packageNames } = resolveModuleFallbackEntries(installAnchor) + if (!moduleFallbackCurrent(modulesDir, entries)) { + await withFileLock(modulesDir, () => { + if (!moduleFallbackCurrent(modulesDir, entries)) healProfilesModuleFallbackLocked(entries, modulesDir) + return Promise.resolve() + }) + } + if (profile !== undefined) healProfileModuleFallback(profile, packageNames) +} + +/** Heal one module-fallback generation while the cross-process writer lock is held. */ +function healProfilesModuleFallbackLocked(entries: readonly ModuleFallbackEntry[], modulesDir: string): void { + for (const entry of entries) { + const link = join(modulesDir, entry.packageName) mkdirSync(dirname(link), { recursive: true }) - ensureSymlink(link, target) + if (entry.kind === 'proxy') { + ensureModuleProxy(link, entry.packageName, entry.version, entry.targets) + } else { + ensureSymlink(link, entry.packageDir) + } + } +} + +/** Collect the first resolvable package directory for each dependency name. */ +function dependencyClosure( + anchors: readonly string[], reserved: ReadonlySet, + exclude: (candidate: string, packageName: string) => boolean, +): Map { + const links = new Map() + const visited = new Set(reserved) + for (const anchor of anchors) { + const canonicalAnchor = realpathSync.native(anchor) + const manifest = readModuleFallbackManifest(canonicalAnchor) + /* v8 ignore next -- an installable package manifest always declares its name */ + if (manifest.name === undefined) continue + if (!visited.has(manifest.name)) { + visited.add(manifest.name) + links.set(manifest.name, dirname(canonicalAnchor)) + } + const queue: { anchor: string; manifest: ProfileManifest }[] = [{ anchor: canonicalAnchor, manifest }] + for (let next = queue.shift(); next !== undefined; next = queue.shift()) { + // Service Provider packages commonly expose Service Definitions as peers. + /* v8 ignore next -- an installable package manifest always declares dependencies or peers */ + for (const dep of profileDependencyNames(next.manifest)) { + if (visited.has(dep)) continue + const dir = packageDirFromAnchor(next.anchor, dep, exclude) + // A declared-but-uninstalled dependency cannot be loader-visible. + if (dir === undefined) continue + visited.add(dep) + links.set(dep, dir) + const manifestPath = join(dir, 'package.json') + queue.push({ anchor: manifestPath, manifest: readModuleFallbackManifest(manifestPath) }) + } + } + } + return links +} + +/** Reconcile packages carried only by selected bundles into one profile. */ +function healProfileModuleFallback(profile: Profile, installationPackageNames: ReadonlySet): void { + const profileModulesDir = join(profile.dir, 'node_modules') + const ownedModulesDir = join(profile.dir, PROFILE_MODULE_FALLBACK_DIR, 'node_modules') + mkdirSync(profileModulesDir, { recursive: true }) + mkdirSync(ownedModulesDir, { recursive: true }) + const bundleAnchors = profile.layers + .filter(layer => !installationPackageNames.has(layer.packageName)) + .map(layer => join(layer.packageDir, 'package.json')) + const bundleLinks = dependencyClosure(bundleAnchors, installationPackageNames, (candidate, packageName) => { + const profileLink = join(profileModulesDir, packageName) + if (canonicalLinkPath(candidate) !== canonicalLinkPath(profileLink)) return false + try { + return lstatSync(profileLink).isSymbolicLink() + && symlinkPointsTo(profileLink, join(ownedModulesDir, packageName)) + } catch (error) { + // A concurrent cleanup may remove the projection after package discovery. + /* v8 ignore next 2 -- a non-ENOENT lstat failure requires a host filesystem fault */ + if ((error as NodeJS.ErrnoException).code === 'ENOENT') return true + /* v8 ignore next -- see the host-filesystem exception above */ + throw error + } + }) + for (const layer of profile.layers) bundleLinks.delete(layer.packageName) + for (const packageName of ownedPackageNames(ownedModulesDir)) { + if (!bundleLinks.has(packageName)) removeProfileSymlink(profileModulesDir, ownedModulesDir, packageName) + } + for (const [packageName, target] of bundleLinks) { + const ownedLink = join(ownedModulesDir, packageName) + mkdirSync(dirname(ownedLink), { recursive: true }) + ensureSymlink(ownedLink, target) + const profileLink = join(profileModulesDir, packageName) + mkdirSync(dirname(profileLink), { recursive: true }) + ensureProfileSymlink(profileLink, ownedLink) } } @@ -291,20 +713,29 @@ function sameBundles(left: readonly string[], right: readonly string[]): boolean } /** - * Normalize an exact installation-owned bundle tuple to its shipped template - * while preserving every other manifest field. Any other list is user-owned. + * Normalize an exact installation-owned bundle tuple to its shipped template, + * or add the shipped reload default to an exact current tuple. A changed value + * is written back during profile loading while every other manifest field is + * preserved; any other bundle list is user-owned and remains untouched. */ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileManifest): ProfileManifest { const installationOwned = INSTALLATION_OWNED_PROFILE_TUPLES[name] - const current = PROFILE_TEMPLATES[name] + const template = PROFILE_TEMPLATES[name] const bundles = manifest.dsh?.profile?.bundles - if (installationOwned === undefined || current === undefined || bundles === undefined - || !sameBundles(bundles, installationOwned)) return manifest + if (template === undefined || bundles === undefined) return manifest + const isRetiredTuple = installationOwned !== undefined && sameBundles(bundles, installationOwned) + const isCurrentTuple = sameBundles(bundles, template.bundles) + const needsReloadDefault = manifest.dsh?.profile?.patchReload === undefined && isCurrentTuple + if (!isRetiredTuple && !needsReloadDefault) return manifest const normalized: ProfileManifest = { ...manifest, dsh: { ...manifest.dsh, - profile: { ...manifest.dsh?.profile, bundles: [...current] }, + profile: { + ...manifest.dsh?.profile, + bundles: [...template.bundles], + patchReload: manifest.dsh?.profile?.patchReload ?? template.patchReload, + }, }, } writeProfileManifest(dir, normalized) @@ -319,12 +750,15 @@ function normalizeShippedProfile(name: string, dir: string, manifest: ProfileMan * matches what the Loader would import from the same anchor, and * `existsSync` follows the symlinks pnpm's isolated layout uses. */ -function packageDirFromAnchor(anchor: string, packageName: string): string | undefined { +function packageDirFromAnchor( + anchor: string, packageName: string, + exclude: (candidate: string, packageName: string) => boolean = () => false, +): string | undefined { // resolve.paths returns null only for builtins, which no bundle name is. /* v8 ignore next */ for (const searchPath of createRequire(anchor).resolve.paths(packageName) ?? []) { const candidate = join(searchPath, packageName) - if (existsSync(join(candidate, 'package.json'))) return candidate + if (existsSync(join(candidate, 'package.json')) && !exclude(candidate, packageName)) return candidate } return undefined } @@ -380,11 +814,18 @@ export function loadProfile( `${binName}: profile ${JSON.stringify(name)} does not exist; create it with 'dsh plugin --profile ${name} add '`, ) } - initProfile(dir, template) + initProfile(dir, template.bundles, template.patchReload) } const manifest = normalizeShippedProfile(name, dir, readProfileManifest(binName, dir)) // A hand-written profile manifest may omit the dsh section entirely. const bundles = manifest.dsh?.profile?.bundles ?? [] + const rawPatchReload: unknown = manifest.dsh?.profile?.patchReload + if (rawPatchReload !== undefined && rawPatchReload !== 'live' && rawPatchReload !== 'startup') { + throw new Error( + `${binName}: profile manifest ${join(dir, 'package.json')} dsh.profile.patchReload must be "live" or "startup"`, + ) + } + const patchReload = rawPatchReload ?? DEFAULT_PROFILE_PATCH_RELOAD const layers = bundles.map((packageName): ProfileLayer => { const packageDir = resolveBundleDir(binName, packageName, installAnchor, dir) const bundleManifest = JSON.parse(readFileSync(join(packageDir, 'package.json'), 'utf8')) as ProfileManifest @@ -399,7 +840,7 @@ export function loadProfile( const patches = options.userLayer !== false && existsSync(patchPath) ? loadOverlayPatches(binName, patchPath) : [] - return { name, dir, layers, patchPath, patches } + return { name, dir, layers, patchPath, patches, patchReload } } /** diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index 9eec620285..e5fc24aaf3 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -133,6 +133,7 @@ describe('loadLayeredEnv', () => { ['a skill root', 'DSH_AGENTS_HOME=/tmp/injected\n'], ['a network proxy', 'HTTPS_PROXY=http://attacker.example\n'], ['a lowercase network proxy', 'https_proxy=http://attacker.example\n'], + ['a browser command', 'BROWSER=./script\n'], ])('refuses to launch when a .env sets %s, before applying anything', (_case, content) => { const home = tmp() const project = tmp() @@ -770,6 +771,28 @@ describe('boot', () => { ) }) + it('expands a stackless aggregate at the deepest activation cause', async () => { + const dir = tmp() + const aggregate = new AggregateError([ + new Error('first aggregate member'), + 'second aggregate member', + ], 'aggregate activation failure') + delete (aggregate as { stack?: string }).stack + try { + await boot(NAME, join(dir, 'cordis.yml'), undefined, () => { + throw new Error('wrapped aggregate failure', { cause: aggregate }) + }) + expect.fail('boot should reject the aggregate activation failure') + } catch (error) { + expect(error).toBeInstanceOf(Error) + const message = (error as Error).message + expect(message).toContain(`${NAME}: host preparation failed: wrapped aggregate failure`) + expect(message).toContain('aggregate activation failure') + expect(message).toContain('first aggregate member') + expect(message).toContain('second aggregate member') + } + }) + it('reports a pending real Loader fiber and the service unresolved in its own context', async () => { const dir = tmp() writeFileSync(join(dir, 'waiting.mjs'), 'export const inject = ["neverProvided"]\nexport function apply() {}\n') @@ -794,8 +817,8 @@ describe('addHarnessSourceSection', () => { const systemPrompt = ctx.get('systemPrompt')! const rendered = renderPrompt(await systemPrompt.assemble()) expect(rendered).toContain(EXPECTED) - // Harness-owned opener (-100) → source (-99) → persona (0). The >= 0 guards - // keep a drifted opener/persona string from a false pass through `-1 < n`. + // The >= 0 guards keep a drifted opener/persona string from a false pass + // through `-1 < n`. const identityAt = rendered.indexOf('You are an AI agent powered by DeepSeek Harness.') const sourceAt = rendered.indexOf(EXPECTED) const personaAt = rendered.indexOf('You are a coding agent.') diff --git a/packages/boot/app-boot/tests/config-dump.spec.ts b/packages/boot/app-boot/tests/config-dump.spec.ts index ed0117b0dc..ef2c9fc68e 100644 --- a/packages/boot/app-boot/tests/config-dump.spec.ts +++ b/packages/boot/app-boot/tests/config-dump.spec.ts @@ -10,6 +10,7 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it, vi } from 'vitest' import * as yaml from 'js-yaml' import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' @@ -74,7 +75,11 @@ describe('renderConfigDump', () => { config: { value: 'surface', key: { __jsExpr: 'process.env.DSH_DUMP_SPEC' } }, }, { id: 'untouched', name: './noop.mjs' }, - { id: 'surface-extra', name: './noop.mjs', config: { value: 'user' } }, + { + id: 'surface-extra', + name: pathToFileURL(join(dir, 'noop.mjs')).href, + config: { value: 'user' }, + }, ]) // Unevaluated: the expression text round-trips as a !!js scalar. expect(dump).toContain('!!js process.env.DSH_DUMP_SPEC') diff --git a/packages/boot/app-boot/tests/profile.spec.ts b/packages/boot/app-boot/tests/profile.spec.ts index bd0294475d..0681157f62 100644 --- a/packages/boot/app-boot/tests/profile.spec.ts +++ b/packages/boot/app-boot/tests/profile.spec.ts @@ -4,9 +4,13 @@ * empty-root composition, and the installation module-fallback healing. */ -import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { + existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, + unlinkSync, writeFileSync, +} from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { withFileLock } from '@deepseek-ai/dsh-atomic-write' import { describe, expect, it } from 'vitest' import { composeEntries, @@ -19,12 +23,16 @@ import { resolveBundleDir, resolveProfileDir, writeProfileManifest, + type Profile, } from '../src/index.ts' const tmp = (): string => mkdtempSync(join(tmpdir(), 'dsh-profile-')) /** Stage a fake installed app: package.json with deps and a node_modules holding bundles. */ -function stageInstallation(bundles: Record }>): string { +function stageInstallation( + bundles: Record }>, + appName = 'dsh-app', +): string { const root = tmp() const appDir = join(root, 'app') mkdirSync(join(appDir, 'node_modules'), { recursive: true }) @@ -36,15 +44,41 @@ function stageInstallation(bundles: Record { it('joins the home and rejects traversal-shaped names', () => { const home = tmp() @@ -62,12 +96,14 @@ describe('initProfile', () => { initProfile(dir, ['@deepseek-ai/dsh-base']) const manifest = readProfileManifest('t', dir) expect(manifest.dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(manifest.dsh?.profile?.patchReload).toBe('live') expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('[]') expect(readFileSync(join(dir, 'pnpm-workspace.yaml'), 'utf8')).toContain('nodeLinker: hoisted') // Re-init keeps user edits. writeFileSync(join(dir, PROFILE_PATCH_FILENAME), '- id: x\n config: {}\n') - initProfile(dir, ['other']) + initProfile(dir, ['other'], 'startup') expect(readProfileManifest('t', dir).dsh?.profile?.bundles).toEqual(['@deepseek-ai/dsh-base']) + expect(readProfileManifest('t', dir).dsh?.profile?.patchReload).toBe('live') expect(readFileSync(join(dir, PROFILE_PATCH_FILENAME), 'utf8')).toContain('- id: x') }) }) @@ -130,6 +166,7 @@ describe('loadProfile', () => { const profile = loadProfile('t', 'demo', anchor, home) expect(profile.layers.map(layer => layer.packageName)).toEqual(['bundle-a', 'bundle-b']) expect(profile.patches).toHaveLength(1) + expect(profile.patchReload).toBe('live') const entries = composeEntries([ ...profile.layers.map(layer => layer.patches), profile.patches, @@ -141,6 +178,7 @@ describe('loadProfile', () => { writeProfileManifest(dir, { name: 'bare' }) const bare = loadProfile('t', 'demo', anchor, home) expect(bare.layers).toEqual([]) + expect(bare.patchReload).toBe('live') }) it('auto-initializes only shipped templates and fails loud otherwise', () => { @@ -151,14 +189,30 @@ describe('loadProfile', () => { // The web template auto-initializes on first load. Bundle resolution // cannot be asserted to fail here: the source-plane test runner resolves // @deepseek-ai/* through tsconfig paths regardless of the staged anchor. - expect(PROFILE_TEMPLATES.web).toContain('@deepseek-ai/dsh-base') + expect(PROFILE_TEMPLATES.web?.bundles).toContain('@deepseek-ai/dsh-base') + expect(PROFILE_TEMPLATES.web?.patchReload).toBe('live') + expect(PROFILE_TEMPLATES.headless?.patchReload).toBe('startup') + expect(PROFILE_TEMPLATES.acp).toEqual({ + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-acp-app'], + patchReload: 'startup', + }) + expect(PROFILE_TEMPLATES.sdk).toEqual({ + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-sdk-app'], + patchReload: 'startup', + }) + expect(PROFILE_TEMPLATES['sdk-minimal']).toEqual({ + bundles: ['@deepseek-ai/dsh-sdk-minimal'], + patchReload: 'startup', + }) try { loadProfile('t', 'web', anchor, home) } catch { // Resolution failure is the plain-Node outcome for this empty anchor. } expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.bundles) - .toEqual([...PROFILE_TEMPLATES.web ?? []]) + .toEqual([...PROFILE_TEMPLATES.web?.bundles ?? []]) + expect(readProfileManifest('t', resolveProfileDir('web', home)).dsh?.profile?.patchReload) + .toBe('live') }) it('normalizes only the exact installation-owned headless bundle tuple', () => { @@ -173,9 +227,14 @@ describe('loadProfile', () => { initProfile(stock, [ '@deepseek-ai/dsh-base', '@deepseek-ai/dsh-web-app', '@deepseek-ai/dsh-headless', ]) + const retiredManifest = readProfileManifest('t', stock) + delete retiredManifest.dsh!.profile!.patchReload + writeProfileManifest(stock, retiredManifest) loadProfile('t', 'headless', anchor, home) - expect(readProfileManifest('t', stock).dsh?.profile?.bundles) - .toEqual(['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless']) + expect(readProfileManifest('t', stock).dsh?.profile).toEqual({ + bundles: ['@deepseek-ai/dsh-base', '@deepseek-ai/dsh-headless'], + patchReload: 'startup', + }) const customHome = tmp() const custom = resolveProfileDir('headless', customHome) @@ -188,6 +247,38 @@ describe('loadProfile', () => { ]) }) + it('adds a shipped reload default only to an exact stock tuple and preserves explicit choices', () => { + const anchor = stageInstallation({ + '@deepseek-ai/dsh-base': { patch: '[]\n' }, + '@deepseek-ai/dsh-web-app': { patch: '[]\n' }, + }) + const stockHome = tmp() + const stock = resolveProfileDir('web', stockHome) + initProfile(stock, PROFILE_TEMPLATES.web?.bundles ?? []) + const stockManifest = readProfileManifest('t', stock) + delete stockManifest.dsh!.profile!.patchReload + writeProfileManifest(stock, stockManifest) + expect(loadProfile('t', 'web', anchor, stockHome).patchReload).toBe('live') + expect(readProfileManifest('t', stock).dsh?.profile?.patchReload).toBe('live') + + const explicitHome = tmp() + const explicit = resolveProfileDir('web', explicitHome) + initProfile(explicit, PROFILE_TEMPLATES.web?.bundles ?? [], 'startup') + expect(loadProfile('t', 'web', anchor, explicitHome).patchReload).toBe('startup') + }) + + it('fails loud on an unknown patch reload value from disk', () => { + const anchor = stageInstallation({}) + const home = tmp() + const dir = resolveProfileDir('demo', home) + initProfile(dir, []) + const manifest = readProfileManifest('t', dir) + const rawProfile = manifest.dsh!.profile as { patchReload?: string } + rawProfile.patchReload = 'sometimes' + writeProfileManifest(dir, manifest) + expect(() => loadProfile('t', 'demo', anchor, home)).toThrow('patchReload must be "live" or "startup"') + }) + it('fails loud when a listed bundle declares no dsh.bundle', () => { const anchor = stageInstallation({ 'not-a-bundle': {} }) const home = tmp() @@ -212,7 +303,7 @@ describe('composeEntries', () => { }) describe('healProfilesModuleFallback', () => { - it('links the app and bundle dependency surface flat under profiles/node_modules', () => { + it('links the app and bundle dependency surface flat under profiles/node_modules', async () => { const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n', deps: { 'dep-of-a': '0.0.0', 'ghost-dep': '0.0.0' } }, 'plain-lib': {}, @@ -226,7 +317,7 @@ describe('healProfilesModuleFallback', () => { mkdirSync(join(modules, 'dep-of-a'), { recursive: true }) writeFileSync(join(modules, 'dep-of-a', 'package.json'), JSON.stringify({ name: 'dep-of-a', version: '0.0.0' })) const home = tmp() - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const fallback = join(home, 'profiles', 'node_modules') // App deps, the bundle's own deps, and the bundle itself are linked; the // plain library is linked as an app dep (harmless), the app itself too. @@ -234,39 +325,634 @@ describe('healProfilesModuleFallback', () => { expect(lstatSync(join(fallback, name)).isSymbolicLink(), name).toBe(true) } // Idempotent, and a moved target is re-pointed. - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) const before = readlinkSync(join(fallback, 'dep-of-a')) expect(before).toContain('dep-of-a') }) - it('throws when a fallback entry is a real directory', () => { + it('throws when a fallback entry is a foreign file or directory', async () => { const anchor = stageInstallation({}) - const home = tmp() - mkdirSync(join(home, 'profiles', 'node_modules', 'dsh-app'), { recursive: true }) - expect(() => { healProfilesModuleFallback(anchor, home) }).toThrow('is not a symlink') + for (const kind of ['file', 'directory']) { + const home = tmp() + const entry = join(home, 'profiles', 'node_modules', 'dsh-app') + mkdirSync(join(entry, '..'), { recursive: true }) + if (kind === 'directory') mkdirSync(entry) + else writeFileSync(entry, '') + await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow('is not a symlink') + } }) - it('replaces a wrong symlink', () => { + it('keeps selected bundle closures profile-local without overriding installation packages', async () => { + const installationAnchor = stageInstallation({ shared: {} }) + const bundleA = stageInstallation({ shared: {}, '@scope/bundle-only': {} }, 'selected-bundle-a') + const bundleB = stageInstallation({ shared: {}, '@scope/bundle-only': {} }, 'selected-bundle-b') + const home = tmp() + const profileA = stageProfile(home, 'a', bundleA) + const profileB = stageProfile(home, 'b', bundleB) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileA, home }) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileA, home }) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile: profileB, home }) + const sharedFallback = join(home, 'profiles', 'node_modules') + const ownedA = join(profileA.dir, '.dsh-module-fallback', 'node_modules', '@scope', 'bundle-only') + const ownedB = join(profileB.dir, '.dsh-module-fallback', 'node_modules', '@scope', 'bundle-only') + + expect(realpathSync.native(readlinkSync(join(sharedFallback, 'shared')))) + .toBe(realpathSync.native(join(installationAnchor, '..', 'node_modules', 'shared'))) + expect(existsSync(join(sharedFallback, '@scope', 'bundle-only'))).toBe(false) + expect(existsSync(join(profileA.dir, 'node_modules', 'shared'))).toBe(false) + expect(existsSync(join(profileB.dir, 'node_modules', 'shared'))).toBe(false) + expect(readlinkSync(join(profileA.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(ownedA) + expect(readlinkSync(ownedA)) + .toBe(realpathSync.native(join(bundleA, '..', 'node_modules', '@scope', 'bundle-only'))) + expect(readlinkSync(join(profileB.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(ownedB) + expect(readlinkSync(ownedB)) + .toBe(realpathSync.native(join(bundleB, '..', 'node_modules', '@scope', 'bundle-only'))) + + await healProfilesModuleFallback({ + installAnchor: installationAnchor, + profile: { ...profileA, layers: [] }, + home, + }) + expect(existsSync(join(profileA.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(false) + expect(existsSync(ownedA)).toBe(false) + expect(existsSync(join(profileB.dir, 'node_modules', '@scope', 'bundle-only'))).toBe(true) + }) + + it('combines packaged installation proxies with profile-local bundle links', async () => { + const installationAnchor = stageInstallation({ shared: {} }) + const bundleAnchor = stageInstallation({ shared: {}, 'bundle-only': {} }, 'selected-bundle') + const home = tmp() + const profile = stageProfile(home, 'packaged', bundleAnchor) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + expect(lstatSync(join(home, 'profiles', 'node_modules', 'shared')).isDirectory()).toBe(true) + expect(lstatSync(join(profile.dir, 'node_modules', 'bundle-only')).isSymbolicLink()).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('discovers dependencies beside a symlinked bundle real path', async () => { + const installationAnchor = stageInstallation({}) + const home = tmp() + const dir = resolveProfileDir('symlinked', home) + const profileModules = join(dir, 'node_modules') + const storeModules = join(tmp(), 'node_modules', '.pnpm', 'selected-bundle@0.0.0', 'node_modules') + const realBundle = join(storeModules, 'selected-bundle') + const realDependency = join(storeModules, 'bundle-only') + mkdirSync(realBundle, { recursive: true }) + mkdirSync(realDependency) + writeFileSync(join(realBundle, 'package.json'), JSON.stringify({ + name: 'selected-bundle', + dependencies: { 'bundle-only': '0.0.0' }, + })) + writeFileSync(join(realDependency, 'package.json'), JSON.stringify({ name: 'bundle-only' })) + mkdirSync(profileModules, { recursive: true }) + const bundleLink = join(profileModules, 'selected-bundle') + symlinkSync(realBundle, bundleLink, 'junction') + const profile: Profile = { + name: 'symlinked', + dir, + layers: [{ + packageName: 'selected-bundle', + packageDir: bundleLink, + patchPath: join(bundleLink, 'cordis.patch.yml'), + patches: [], + }], + patchPath: join(dir, PROFILE_PATCH_FILENAME), + patches: [], + patchReload: 'live', + } + + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + + expect(readlinkSync(join(dir, '.dsh-module-fallback', 'node_modules', 'bundle-only'))) + .toBe(realpathSync.native(realDependency)) + }) + + it('traverses every explicit bundle root even when a nested package has the same name', async () => { + const installationAnchor = stageInstallation({}) + const home = tmp() + const root = tmp() + const bundleA = join(root, 'bundle-a') + const nestedBundleB = join(bundleA, 'node_modules', 'bundle-b') + const nestedOnly = join(nestedBundleB, 'node_modules', 'nested-only') + const bundleB = join(root, 'bundle-b') + const explicitOnly = join(bundleB, 'node_modules', 'explicit-only') + for (const dir of [bundleA, nestedBundleB, nestedOnly, bundleB, explicitOnly]) mkdirSync(dir, { recursive: true }) + writeFileSync(join(bundleA, 'package.json'), JSON.stringify({ + name: 'bundle-a', + dependencies: { 'bundle-b': '0.0.0' }, + })) + writeFileSync(join(nestedBundleB, 'package.json'), JSON.stringify({ + name: 'bundle-b', + dependencies: { 'nested-only': '0.0.0' }, + })) + writeFileSync(join(nestedOnly, 'package.json'), JSON.stringify({ name: 'nested-only' })) + writeFileSync(join(bundleB, 'package.json'), JSON.stringify({ + name: 'bundle-b', + dependencies: { 'explicit-only': '0.0.0' }, + })) + writeFileSync(join(explicitOnly, 'package.json'), JSON.stringify({ name: 'explicit-only' })) + const dir = resolveProfileDir('explicit-roots', home) + const profile: Profile = { + name: 'explicit-roots', + dir, + layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({ + packageName, + packageDir, + patchPath: join(packageDir, 'cordis.patch.yml'), + patches: [], + })), + patchPath: join(dir, PROFILE_PATCH_FILENAME), + patches: [], + patchReload: 'live', + } + + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + + const ownedModules = join(dir, '.dsh-module-fallback', 'node_modules') + expect(readlinkSync(join(ownedModules, 'nested-only'))).toBe(realpathSync.native(nestedOnly)) + expect(readlinkSync(join(ownedModules, 'explicit-only'))).toBe(realpathSync.native(explicitOnly)) + }) + + it('ignores owned projections while recomputing an ordered bundle closure', async () => { + const installationAnchor = stageInstallation({}) + const home = tmp() + const dir = resolveProfileDir('ordered', home) + const profileModules = join(dir, 'node_modules') + const bundleA = join(profileModules, 'bundle-a') + const bundleB = join(profileModules, 'bundle-b') + const nested = join(bundleB, 'node_modules', 'bundle-only') + mkdirSync(bundleA, { recursive: true }) + mkdirSync(nested, { recursive: true }) + writeFileSync(join(bundleA, 'package.json'), JSON.stringify({ + name: 'bundle-a', + peerDependencies: { 'bundle-only': '0.0.0' }, + })) + writeFileSync(join(bundleB, 'package.json'), JSON.stringify({ + name: 'bundle-b', + dependencies: { 'bundle-only': '0.0.0' }, + })) + writeFileSync(join(nested, 'package.json'), JSON.stringify({ name: 'bundle-only' })) + const profile: Profile = { + name: 'ordered', + dir, + layers: ([['bundle-a', bundleA], ['bundle-b', bundleB]] as const).map(([packageName, packageDir]) => ({ + packageDir, + packageName, + patchPath: join(packageDir, 'cordis.patch.yml'), + patches: [], + })), + patchPath: join(dir, PROFILE_PATCH_FILENAME), + patches: [], + patchReload: 'live', + } + + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + + const owned = join(dir, '.dsh-module-fallback', 'node_modules', 'bundle-only') + expect(readlinkSync(owned)).toBe(realpathSync.native(nested)) + expect(JSON.parse(readFileSync(join(profileModules, 'bundle-only', 'package.json'), 'utf8'))) + .toMatchObject({ name: 'bundle-only' }) + }) + + it('cleans owned projections without removing profile-managed entries', async () => { + const installationAnchor = stageInstallation({}) + const bundleAnchor = stageInstallation({ fallback: {}, 'managed-dir': {}, 'managed-link': {} }, 'selected-bundle') + const home = tmp() + const profile = stageProfile(home, 'managed', bundleAnchor) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + const ownedModules = join(profile.dir, '.dsh-module-fallback', 'node_modules') + const profileModules = join(profile.dir, 'node_modules') + const foreignTarget = tmp() + unlinkSync(join(profileModules, 'managed-dir')) + mkdirSync(join(profileModules, 'managed-dir')) + unlinkSync(join(profileModules, 'managed-link')) + symlinkSync(foreignTarget, join(profileModules, 'managed-link'), 'junction') + mkdirSync(join(ownedModules, 'foreign-directory')) + mkdirSync(join(ownedModules, '@foreign', 'directory'), { recursive: true }) + + await healProfilesModuleFallback({ + installAnchor: installationAnchor, + profile: { ...profile, layers: [] }, + home, + }) + + expect(existsSync(join(profileModules, 'fallback'))).toBe(false) + expect(lstatSync(join(profileModules, 'managed-dir')).isDirectory()).toBe(true) + expect(readlinkSync(join(profileModules, 'managed-link'))).toBe(foreignTarget) + expect(existsSync(join(ownedModules, 'fallback'))).toBe(false) + expect(existsSync(join(ownedModules, 'managed-dir'))).toBe(false) + expect(existsSync(join(ownedModules, 'managed-link'))).toBe(false) + }) + + it('cleans owned projections whose junction target uses a canonical parent path', async () => { + const installationAnchor = stageInstallation({}) + const realHome = tmp() + const aliasRoot = tmp() + const home = join(aliasRoot, 'home') + symlinkSync(realHome, home, 'junction') + const bundleAnchor = stageInstallation({ fallback: {} }, 'selected-bundle') + const profile = stageProfile(home, 'canonical', bundleAnchor) + await healProfilesModuleFallback({ installAnchor: installationAnchor, profile, home }) + const profileLink = join(profile.dir, 'node_modules', 'fallback') + const ownedModules = join(profile.dir, '.dsh-module-fallback', 'node_modules') + unlinkSync(profileLink) + symlinkSync(join(realpathSync(ownedModules), 'fallback'), profileLink, 'junction') + + await healProfilesModuleFallback({ + installAnchor: installationAnchor, + profile: { ...profile, layers: [] }, + home, + }) + + expect(existsSync(profileLink)).toBe(false) + expect(existsSync(join(ownedModules, 'fallback'))).toBe(false) + }) + + it('replaces a wrong symlink', async () => { const anchor = stageInstallation({}) const home = tmp() const fallback = join(home, 'profiles', 'node_modules') mkdirSync(fallback, { recursive: true }) symlinkSync(tmp(), join(fallback, 'dsh-app'), 'junction') - healProfilesModuleFallback(anchor, home) + await healProfilesModuleFallback({ installAnchor: anchor, home }) expect(readlinkSync(join(fallback, 'dsh-app'))).toContain('app') }) - it('tolerates losing the concurrent-heal race to an identical link and rejects a different one', () => { - // The EEXIST arm: a second process wrote the link between our lstat miss - // and symlinkSync. Simulated by pre-creating the correct link and calling - // the internal path through a stale-lstat shim is not possible from - // outside, so probe the observable contract: healing twice concurrently - // is a no-op, and a foreign REAL directory still fails loud. + it('retains current links while repairing a missing sibling', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules') + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const appTarget = readlinkSync(join(fallback, 'dsh-app')) + unlinkSync(join(fallback, 'bundle-a')) + + await healProfilesModuleFallback({ installAnchor: anchor, home }) + + expect(readlinkSync(join(fallback, 'dsh-app'))).toBe(appTarget) + expect(lstatSync(join(fallback, 'bundle-a')).isSymbolicLink()).toBe(true) + }) + + it('serializes concurrent healers and retains the identical link', async () => { const anchor = stageInstallation({}) const home = tmp() - healProfilesModuleFallback(anchor, home) - healProfilesModuleFallback(anchor, home) // second healer sees the correct link + await Promise.all([ + healProfilesModuleFallback({ installAnchor: anchor, home }), + healProfilesModuleFallback({ installAnchor: anchor, home }), + ]) const fallback = join(home, 'profiles', 'node_modules') expect(lstatSync(join(fallback, 'dsh-app')).isSymbolicLink()).toBe(true) }) + + it('does not acquire the writer lock for a complete generation', async () => { + const anchor = stageInstallation({}) + const home = tmp() + const modules = join(home, 'profiles', 'node_modules') + await healProfilesModuleFallback({ installAnchor: anchor, home }) + let releaseLock: (() => void) | undefined + let reportLock: (() => void) | undefined + const lockHeld = new Promise((resolve) => { reportLock = resolve }) + const release = new Promise((resolve) => { releaseLock = resolve }) + const holder = withFileLock(modules, async () => { + reportLock?.() + await release + }) + await lockHeld + + const healer = healProfilesModuleFallback({ installAnchor: anchor, home }) + const outcome = await Promise.race([ + healer.then(() => 'complete' as const), + new Promise<'blocked'>(resolve => setTimeout(() => { resolve('blocked') }, 100)), + ]) + releaseLock?.() + await Promise.all([holder, healer]) + expect(outcome).toBe('complete') + }) + + it('waits for the module-fallback writer lock before publishing entries', async () => { + const anchor = stageInstallation({}) + const home = tmp() + const modules = join(home, 'profiles', 'node_modules') + mkdirSync(modules, { recursive: true }) + let releaseLock: (() => void) | undefined + let reportLock: (() => void) | undefined + const lockHeld = new Promise((resolve) => { reportLock = resolve }) + const release = new Promise((resolve) => { releaseLock = resolve }) + const holder = withFileLock(modules, async () => { + reportLock?.() + await release + }) + await lockHeld + + const healer = healProfilesModuleFallback({ installAnchor: anchor, home }) + await new Promise(resolve => setTimeout(resolve, 20)) + expect(existsSync(join(modules, 'dsh-app'))).toBe(false) + releaseLock?.() + await Promise.all([holder, healer]) + expect(lstatSync(join(modules, 'dsh-app')).isSymbolicLink()).toBe(true) + }) + + it('writes real ESM proxies for a packaged executable', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + bundleManifest.exports = { + '.': './index.js', + './feature': './feature.js', + './legacy/': './legacy/', + './types': { types: './feature.d.ts' }, + } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + writeFileSync(join(bundleDir, 'feature.js'), 'export const feature = "proxied"\n') + const home = tmp() + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const fallback = join(home, 'profiles', 'node_modules') + const proxy = join(fallback, 'bundle-a') + expect(lstatSync(proxy).isDirectory()).toBe(true) + const proxyManifest = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { + version: unknown + exports: unknown + dsh: { moduleFallback: { targets: Record } } + } + expect(proxyManifest).toMatchObject({ + version: '0.0.0', + exports: { '.': './entry-0.js', './feature': './entry-1.js' }, + }) + expect(proxyManifest.dsh.moduleFallback.targets['.']).toEqual(expect.stringContaining('/bundle-a/index.js')) + await expect(import(join(proxy, 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) + await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ feature: 'proxied' }) + await healProfilesModuleFallback({ installAnchor: anchor, home }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('resolves import-only exports from each package installation', async () => { + const anchor = stageInstallation({ + 'bundle-a': { patch: '[]\n', deps: { 'nested-esm': '0.0.0' } }, + }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const bundleManifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + bundleManifest.exports = { '.': { import: './index.js' } } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(bundleManifest)) + const nestedDir = join(bundleDir, 'node_modules', 'nested-esm') + mkdirSync(nestedDir, { recursive: true }) + writeFileSync(join(nestedDir, 'package.json'), JSON.stringify({ + name: 'nested-esm', + version: '0.0.0', + type: 'module', + exports: { import: './index.js' }, + })) + writeFileSync(join(nestedDir, 'index.js'), 'export const nested = "proxied"\n') + const home = tmp() + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const fallback = join(home, 'profiles', 'node_modules') + await expect(import(join(fallback, 'bundle-a', 'entry-0.js'))).resolves.toMatchObject({ packageName: 'bundle-a' }) + await expect(import(join(fallback, 'nested-esm', 'entry-0.js'))).resolves.toMatchObject({ nested: 'proxied' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('resolves explicit condition targets without filesystem package lookup', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.exports = { + '.': { import: './index.js', require: './index.cjs' }, + './mini': { types: './mini/index.d.ts', import: './mini/index.js', require: './mini/index.cjs' }, + './web': { types: './dist/web/web.d.ts', import: './dist/web/index.mjs', default: './dist/web/index.mjs' }, + } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + mkdirSync(join(bundleDir, 'mini')) + writeFileSync(join(bundleDir, 'mini', 'index.js'), 'export const mini = true\n') + mkdirSync(join(bundleDir, 'dist', 'web'), { recursive: true }) + writeFileSync(join(bundleDir, 'dist', 'web', 'index.mjs'), 'export const web = true\n') + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + await expect(import(join(proxy, 'entry-1.js'))).resolves.toMatchObject({ mini: true }) + await expect(import(join(proxy, 'entry-2.js'))).resolves.toMatchObject({ web: true }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('preserves the installation path while resolving packaged exports', async () => { + const anchor = stageInstallation({}) + const appDir = join(anchor, '..') + const physical = tmp() + writeFileSync(join(physical, 'package.json'), JSON.stringify({ + name: 'linked-esm', + version: '0.0.0', + type: 'module', + exports: { import: './index.js' }, + })) + writeFileSync(join(physical, 'index.js'), 'export const linked = true\n') + symlinkSync(physical, join(appDir, 'node_modules', 'linked-esm'), 'junction') + const appManifest = JSON.parse(readFileSync(anchor, 'utf8')) as { dependencies: Record } + appManifest.dependencies['linked-esm'] = '0.0.0' + writeFileSync(anchor, JSON.stringify(appManifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const proxyManifest = JSON.parse(readFileSync( + join(home, 'profiles', 'node_modules', 'linked-esm', 'package.json'), + 'utf8', + )) as { dsh: { moduleFallback: { targets: Record } } } + expect(proxyManifest.dsh.moduleFallback.targets['.']).toContain('/app/node_modules/linked-esm/index.js') + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('uses the legacy index fallback when a package has no exports or main', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + delete manifest.main + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback({ installAnchor: anchor, home }) + await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) + .resolves.toMatchObject({ packageName: 'bundle-a' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('uses Node legacy resolution for an extensionless main entry', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.main = './index' + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback({ installAnchor: anchor, home }) + await expect(import(join(home, 'profiles', 'node_modules', 'bundle-a', 'entry-0.js'))) + .resolves.toMatchObject({ packageName: 'bundle-a' }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('skips executable-only and declaration-only packages without import entries', async () => { + for (const marker of ['bin', 'types', 'typings']) { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const manifest = JSON.parse(readFileSync(anchor, 'utf8')) as Record + delete manifest.main + manifest[marker] = marker === 'bin' ? { dsh: './lib/bin.js' } : './index.d.ts' + if (marker === 'types') manifest.main = '' + writeFileSync(anchor, JSON.stringify(manifest)) + rmSync(join(anchor, '..', 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const fallback = join(home, 'profiles', 'node_modules') + expect(existsSync(join(fallback, 'dsh-app'))).toBe(false) + expect(existsSync(join(fallback, 'bundle-a', 'entry-0.js'))).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + } + }) + + it('fails loud on a missing legacy main entry', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + delete manifest.main + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + rmSync(join(bundleDir, 'index.js')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await expect(healProfilesModuleFallback({ installAnchor: anchor, home: tmp() })).rejects.toThrow('main entry is missing') + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('omits unavailable ESM exports and rejects malformed export targets', async () => { + for (const mode of ['missing', 'directory', 'absent-map', 'invalid', 'escape', 'null', 'null-subpath']) { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + const target = mode === 'missing' ? './missing.js' + : mode === 'directory' ? './mini' + : mode === 'escape' ? './../outside.js' + : '../outside.js' + manifest.exports = mode === 'absent-map' ? null + : mode === 'null-subpath' ? { './bad': null } + : { '.': mode === 'null' ? null : { import: target } } + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + if (mode === 'directory') mkdirSync(join(bundleDir, 'mini')) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + const home = tmp() + if (mode === 'missing' || mode === 'directory' || mode === 'absent-map') { + await healProfilesModuleFallback({ installAnchor: anchor, home }) + expect(existsSync(join(home, 'profiles', 'node_modules', 'bundle-a'))).toBe(false) + } else { + await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow( + mode === 'null' || mode === 'null-subpath' + ? 'cannot resolve ESM export bundle-a' + : 'resolves outside its package', + ) + } + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + } + }) + + it('requires a package version before writing a packaged proxy', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const bundleDir = join(anchor, '..', 'node_modules', 'bundle-a') + const manifest = JSON.parse(readFileSync(join(bundleDir, 'package.json'), 'utf8')) as Record + manifest.version = '' + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify(manifest)) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await expect(healProfilesModuleFallback({ installAnchor: anchor, home: tmp() })).rejects.toThrow( + 'installed package bundle-a must declare a non-empty version', + ) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('replaces plain-node links and stale managed proxies in packaged mode', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + await healProfilesModuleFallback({ installAnchor: anchor, home }) + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + expect(lstatSync(proxy).isSymbolicLink()).toBe(true) + + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback({ installAnchor: anchor, home }) + expect(lstatSync(proxy).isDirectory()).toBe(true) + const stale = JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8')) as { + version: string + } + stale.version = 'stale' + writeFileSync(join(proxy, 'package.json'), JSON.stringify(stale)) + await healProfilesModuleFallback({ installAnchor: anchor, home }) + expect(JSON.parse(readFileSync(join(proxy, 'package.json'), 'utf8'))).toMatchObject({ + version: '0.0.0', + }) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) + + it('replaces a managed packaged proxy with a plain-node symlink', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + const home = tmp() + const fallback = join(home, 'profiles', 'node_modules', 'bundle-a') + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + await healProfilesModuleFallback({ installAnchor: anchor, home }) + expect(lstatSync(fallback).isDirectory()).toBe(true) + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + + await healProfilesModuleFallback({ installAnchor: anchor, home }) + expect(lstatSync(fallback).isSymbolicLink()).toBe(true) + }) + + it('rejects foreign packaged fallback directories with valid or invalid metadata', async () => { + const anchor = stageInstallation({ 'bundle-a': { patch: '[]\n' } }) + Object.defineProperty(process, 'pkg', { configurable: true, value: {} }) + try { + for (const metadata of ['{}', '{']) { + const home = tmp() + const proxy = join(home, 'profiles', 'node_modules', 'bundle-a') + mkdirSync(proxy, { recursive: true }) + writeFileSync(join(proxy, 'package.json'), metadata) + await expect(healProfilesModuleFallback({ installAnchor: anchor, home })).rejects.toThrow( + 'exists and is not a dsh-managed module proxy', + ) + } + } finally { + delete (process as NodeJS.Process & { pkg?: unknown }).pkg + } + }) }) diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 0cb44e55bf..3efdd22db5 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -65,6 +65,31 @@ describe('loadOptionalPatches', () => { expect(patches?.[1]?.insert).toHaveLength(1) }) + it('anchors inserted relative plugins to the patch file and keeps assertion names literal', () => { + const dir = tmp() + const patchPath = join(dir, PROFILE_PATCH_FILENAME) + writeFileSync(patchPath, [ + '- id: existing', + ' name: ./assertion.mjs', + '- insert:', + ' - id: rule', + ' name: ./rule.mjs', + ' - id: nested', + ' name: cordis:group', + ' group: true', + ' config:', + ' - id: child', + ' name: ../child.mjs', + '', + ].join('\n')) + + const patches = loadOptionalPatches(NAME, patchPath) + expect(patches?.[0]?.name).toBe('./assertion.mjs') + expect(patches?.[1]?.insert?.[0]?.name).toBe(pathToFileURL(join(dir, 'rule.mjs')).href) + expect((patches?.[1]?.insert?.[1]?.config as { name: string }[])[0]?.name) + .toBe(pathToFileURL(join(dir, '..', 'child.mjs')).href) + }) + it('fails loud on an unreadable file (a present user patch layer is never skipped)', () => { const dir = tmp() mkdirSync(join(dir, PROFILE_PATCH_FILENAME)) // a directory: present, unreadable as a file @@ -271,6 +296,12 @@ describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() const userDir = tmp() + writeFileSync(join(userDir, 'noop.mjs'), [ + 'export function apply(_ctx, config = {}) {', + ' if (config.fail) throw new Error("candidate config failed")', + '}', + '', + ].join('\n')) writeFileSync(join(userDir, PROFILE_PATCH_FILENAME), [ '- id: noop', ' name: ./noop.mjs', diff --git a/packages/boot/app-boot/tsconfig.json b/packages/boot/app-boot/tsconfig.json index 6f0e03b8fb..c866abbc25 100644 --- a/packages/boot/app-boot/tsconfig.json +++ b/packages/boot/app-boot/tsconfig.json @@ -29,6 +29,9 @@ { "path": "../../core/system-prompt" }, + { + "path": "../../util/atomic-write" + }, { "path": "../../util/launch-environment" }, diff --git a/packages/boot/cmdline/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml index 22a80a7e13..2d4371b1a9 100644 --- a/packages/boot/cmdline/README.i18n.yaml +++ b/packages/boot/cmdline/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md -README.md: 33125014539e801dbd2952a3b4513cafc80bdcee -README.zh.md: 7ef49a1027d3c17817c9171e1166ed6feecd8559 +README.md: 7ea825d8ef309cc295e3334bf5ae220e3e0524e5 +README.zh.md: 9f5f876ec6d1b408e2156dc3075499885032a141 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md index 3312501453..7ea825d8ef 100644 --- a/packages/boot/cmdline/README.md +++ b/packages/boot/cmdline/README.md @@ -1,41 +1,54 @@ -# `@deepseek-ai/dsh-cmdline` +--- +description: "App-owned command lines for dsh app bins: your app parses its own flags, --help, and exit behavior from the launcher's remaining arguments." +kind: "package-library" +--- + +# @deepseek-ai/dsh-cmdline English | [中文](README.zh.md) -The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. +## Summary -## The launcher values +`dsh-cmdline` lets your app own its command line: the launcher keeps only its own flags (`--profile`, `--patch`, the config dumps) and passes everything after them to your app verbatim, so your app decides its flags, its `--help` text, and its parse errors. Values you parse from those arguments win over any default written in the config, without writing anything back. Your app also gets a bounded way to ask for process exit, wired to the launcher's shutdown. Use it when you write an app bin that accepts its own flags; it adds no prompt, schema, or model-facing surface of its own. -A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: +## Table of Contents -- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. -- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. +----- -## Ordinary providers and injected config + +## Use this package -Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program)` is only a commander adapter; the program's own action owns validation and the published service: +Your app reads the invocation's inner arguments at startup, and any number of its plugins can use them. The common path: a startup plugin reads the arguments, parses them, and publishes the parsed values; other rows configure themselves from those values. -```ts ignore -export const name = 'web-startup' -export const inject = ['cmdlineArgs'] +### The launcher values -export function apply(ctx: Context): void { - const program = webCommand() - program.action(() => ctx.provide('webStartup', webValuesFrom(program))) - parseCmdline(ctx, program) -} -``` +The launcher makes three things available to your app: -Its Loader row carries no launcher marker or special kind: +- `ctx.cmdlineArgs` — the inner arguments of your invocation. Reading them returns an immutable snapshot and never consumes or changes them: `dsh --profile tui --resume abc` gives your app `['--resume', 'abc']`. +- `ctx.appExit` — a way to ask the process to exit once the tree has shut down, wired to the launcher's shutdown controller. +- `ctx.appReady` — the successful-startup signal, committed only after the Loader tree and launcher-owned setup succeed. + +An app launched with no arguments sees an empty list — that is the honest answer, not a missing value. + +`exitOnStdinEnd(ctx, label)` binds a successfully started stdio application's EOF to `ctx.appExit(0)`. It never reads or resumes stdin, so a protocol transport receives bytes buffered before it mounts; startup rejection wins over a racing EOF, and the owning fiber removes both pending listeners. + +### Parsing your flags + +You bring your own commander program: declare your flags and your actions, and the package runs it against the inner arguments. Your action is the only place validation happens, and it publishes whatever your rows need. The plugin's Loader row carries no special marker: ```yaml - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' ``` -Every row configured from those values uses ordinary service injection and direct lazy config access: +Rows configured from the parsed values inject the published service and read it directly in their config: ```yaml - id: webserver @@ -46,21 +59,67 @@ Every row configured from those values uses ordinary service injection and direc port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` refuses at load a program in which no command declares an action, routes every command's exit and output through the launcher (commander copies those settings into subcommands only at registration), and parses the immutable arguments; commander runs the invoked command's synchronous action on success. An action rejects an invalid invocation with `program.error(...)` — before publishing, since statements ahead of the rejection have already run. On `--help`, `--version`, a parse error, or that rejection, the helper writes commander's text and requests exit; the provider publishes nothing, so dependent rows never activate. +The outcomes: `dsh --profile web --port 8080` starts the server on port 8080 even when the config says 3080, because the flag wins. `--help` prints your app's help and exits 0 without starting anything; a rejected value (for example a non-numeric port) prints your error and exits nonzero, and no row that depends on the parsed values ever starts. -### How injection orders config +### How flags beat config values -Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset. +The value written beside a `!!js` expression is the fallback: the flag wins when present, the written value is used otherwise. Resolution happens once at startup, after your parser ran, so a flag is never silently reset by a later config reload. -### Shared immutable arguments +### Reading the same arguments from several plugins -`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments. +Any number of plugins can read the same arguments — reading never consumes them — and each can parse what it needs and publish its own values. The launcher does not decide who owns the command line: an app with no reader ignores its arguments. -An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. +Apps built outside this repository behave the same way: their `--help` prints and exits instead of crashing, even though they carry their own commander copy. +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains how the outcomes above are realized and points at the code that realizes them; everything here is developer-facing and not needed to use the package. + +### Design notes + +- **Launcher facts, not config.** `cmdlineArgs` and `appExit` are provided on the host context before the tree mounts; they are not Loader rows, so no composition owns or overrides them. +- **Positional split.** The launcher recognizes no app row: the first token after its own flags starts the app's arguments, so the app owns its flag family, its `--help` text, and its parse errors. +- **Structural error detection.** `isCommanderError` reads commander's error code prefix instead of using `instanceof`, because an out-of-tree plugin brings its own commander copy whose `CommanderError` identity differs; `configureExitAndOutput` walks every subcommand because commander copies exit and output settings only at registration. +- **Injectable output streams.** `internals` holds the output streams so tests can capture commander's text without touching the process. + +### Parsing contract + +The parse path is one small family with two owners: `provideCmdline` freezes the host arguments and provides `cmdlineArgs` and `appExit` before any tree entry mounts, and `parseCmdline` runs your commander program against the immutable arguments, routing every command's help, version, and error output through the launcher. A rejected value, `--help`, or `--version` prints commander's text and requests `ctx.appExit` without publishing anything, so dependent rows never activate; Loader defers each row's `!!js` interpolation until its declared injections are active. Per-export contracts live in the code, not this README — see [`src/index.ts`](src/index.ts). + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `CmdlineArgs`/`AppExit` types, `provideCmdline`, `parseCmdline`, commander exit/output routing | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; Loader settlement reports missing services) | + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the handoff mechanism to the apps that consume it and the decisions behind it. + +- [App-owned command-line decision](../../../.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md) — why apps own their flag family and how the handoff works. +- [Command-line seam trim](../../../.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.md) — the seams reduced to existing interfaces. +- [dsh-app-boot](../app-boot/README.md) — the boot sequence that provides these launcher values. +- [dsh-web-app bundle](../../bundle/web-app/README.md) — an app that owns the Web flag family through this package. +- [dsh-headless bundle](../../bundle/headless/README.md) — the one-shot runner that reads its task from the command line. + +----- + + ## Model Experience -None, as this package resolves the process's own command line before any session exists. +None, as this package resolves the process command line before any session exists; configured rows own every model-visible consequence. #### KV Cache effect @@ -68,6 +127,25 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. -- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load. -- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. + + + +These limits describe where app-owned command lines are a poor fit or need special care. They are current package constraints, not a task backlog. + +- **Launcher flags must precede app arguments** — the split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. +- **An app-owned service has no statically declared provider** — consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load. +- **A user patch that replaces a row's whole `config` drops its expressions** — a flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is working context for maintainers: open design questions and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above, the package code, and the linked Agent Notes. + +#### Open: parser surface + +`parseCmdline` is a commander adapter, not a command-line framework: help, version, and error output follow commander's formatting, and the exit/output routing assumes commander's control-flow model. A different parser would need its own routing and error handling; nothing in the `cmdlineArgs` service contract depends on commander. + +
diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md index 7ef49a1027..9f5f876ec6 100644 --- a/packages/boot/cmdline/README.zh.md +++ b/packages/boot/cmdline/README.zh.md @@ -1,41 +1,54 @@ -# `@deepseek-ai/dsh-cmdline` +--- +description: "dsh app bin 的应用自有命令行:应用从启动器剩余参数中解析自己的 flag、--help 与退出行为。" +kind: "package-library" +--- + +# @deepseek-ai/dsh-cmdline [English](README.md) | 中文 -dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 +## 概述 -## 启动器提供的值 +`dsh-cmdline` 让你的应用持有自己的命令行:启动器只保留属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给你的应用,因此 flag、`--help` 文本与解析错误都由你的应用决定。你从这些参数解析出的值会胜过配置中写下的任何默认值,且无需写回任何内容。你的应用还获得一个有边界的进程退出请求,接到启动器的关停上。当你编写接受自有 flag 的应用 bin 时使用它;它本身不增加任何提示词、schema 或面向模型的表面。 -启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: +## 目录 -- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 -- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 +----- -## 普通提供方与注入配置 + +## 使用本包 -任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program)` 只适配 commander;校验与发布的服务都归 program 自己的 action 持有: +你的应用在启动时读取本次调用的内层参数,任意数量的插件都可以使用它们。常用路径是:启动插件读取参数、解析它们,再发布解析后的值;其他行由这些值配置自身。 -```ts ignore -export const name = 'web-startup' -export const inject = ['cmdlineArgs'] +### 启动器提供的值 -export function apply(ctx: Context): void { - const program = webCommand() - program.action(() => ctx.provide('webStartup', webValuesFrom(program))) - parseCmdline(ctx, program) -} -``` +启动器向你的应用提供三样东西: -它的 Loader 行不携带启动器标记,也没有特殊类型: +- `ctx.cmdlineArgs`——本次调用的内层参数。读取它返回一份不可变快照,且绝不会消费或修改它们:`dsh --profile tui --resume abc` 给你的应用 `['--resume', 'abc']`。 +- `ctx.appExit`——在整棵树关闭后请求进程退出的方式,接到启动器的关停控制器上。 +- `ctx.appReady`——成功启动信号,只在 Loader 树与 launcher 自有设置成功后提交。 + +没有参数的启动会看到空列表——这是诚实的答案,而不是缺失的值。 + +`exitOnStdinEnd(ctx, label)` 把已成功启动的 stdio 应用 EOF 绑定到 `ctx.appExit(0)`。它绝不读取或恢复 stdin,因此协议传输会收到挂载前已缓冲的字节;启动拒绝优先于竞态 EOF,拥有它的 fiber 会移除两项待处理监听。 + +### 解析你的 flag + +你自带自己的 commander program:声明你的 flag 与 action,本包会针对内层参数运行它。校验只发生在你的 action 中,并由它发布你的行所需的任何值。插件的 Loader 行不携带特殊标记: ```yaml - id: web-startup name: '@deepseek-ai/dsh-web-app/startup' ``` -所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务: +由解析值配置的行注入发布的服务,并在其配置中直接读取它: ```yaml - id: webserver @@ -46,21 +59,67 @@ export function apply(ctx: Context): void { port: !!js ctx.webStartup.port ?? 3080 ``` -`parseCmdline` 在加载时拒绝整棵命令树中没有任何命令声明 action 的 program,把每个命令的退出与输出都接到启动器上(commander 只在注册时把这些设置复制进子命令),再解析不可变参数;解析成功时 commander 运行被调用命令的同步 action。action 用 `program.error(...)` 拒绝无效调用——必须先拒绝后发布,因为写在拒绝之前的语句已经执行。遇到 `--help`、`--version`、解析错误或这种拒绝时,该适配器输出 commander 文本并请求退出;提供方什么也不发布,因此依赖行不会激活。 +结果:即使配置写的是 3080,`dsh --profile web --port 8080` 也会让服务器监听 8080 端口,因为 flag 优先。`--help` 打印你的应用帮助并以 0 退出、不启动任何内容;被拒绝的值(例如非数字端口)打印你的错误并以非零码退出,任何依赖解析值的行都不会启动。 -### 注入如何排列配置求值 +### flag 如何胜过配置值 -Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 +写在 `!!js` 表达式旁的值是后备:flag 存在时 flag 优先,否则使用写下的值。解析在启动时、你的解析器运行之后发生一次,因此 flag 绝不会被之后的配置重载悄悄重置。 -### 共享不可变参数 +### 多个插件读取同一份参数 -`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。 +任意数量的插件都可以读取同一份参数——读取绝不会消费它们——每个插件都能解析自己需要的部分并发布各自的值。启动器不会决定谁是命令行的所有者:没有读取方的应用会忽略自己的参数。 -树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 +本仓库之外构建的应用行为一致:即使它们自带 commander 副本,其 `--help` 也会打印并退出,而不是崩溃。 +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释上述结果如何实现,并指出实现它们的代码位置;这里的内容面向开发者,使用本包并不需要。 + +### 设计说明 + +- **启动器事实,而非配置。** `cmdlineArgs` 与 `appExit` 在树挂载前提供到宿主上下文上;它们不是 Loader 行,因此没有任何组合持有或覆盖它们。 +- **按位置切分。** 启动器不认识任何应用行:自身 flag 之后的第一个 token 就是应用参数的起点,因此 flag 家族、`--help` 文本与解析错误都由应用自己持有。 +- **结构化错误识别。** `isCommanderError` 读取 commander 的错误码前缀,而不是用 `instanceof`,因为树外插件会带来自己的一份 commander 副本,其 `CommanderError` 身份不同;`configureExitAndOutput` 会遍历每个子命令,因为 commander 只在注册时复制退出与输出设置。 +- **可注入的输出流。** `internals` 持有输出流,使测试无需触碰进程即可捕获 commander 的文本。 + +### 解析约定 + +解析路径是一个只有两个所有者的小家族:`provideCmdline` 冻结宿主参数,并在任何配置树条目挂载前提供 `cmdlineArgs` 与 `appExit`;`parseCmdline` 针对不可变参数运行你的 commander program,把每个命令的 help、version 与错误输出都接到启动器上。被拒绝的值、`--help` 或 `--version` 会打印 commander 文本并请求 `ctx.appExit`,且不发布任何内容,因此依赖行绝不会激活;Loader 会把每行的 `!!js` 插值推迟到该行声明的注入全部激活之后。各导出的约定在代码中,不在本 README——见 [`src/index.ts`](src/index.ts)。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `CmdlineArgs`/`AppExit` 类型、`provideCmdline`、`parseCmdline`、commander 退出/输出路由 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;Loader 结算会报告缺失的服务) | + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从交接机制逐步进入消费它的应用及其背后的决策。 + +- [应用持有命令行决策](../../../.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md)——为什么 flag 家族由应用持有,以及交接如何运作。 +- [命令行 seam 精简](../../../.agents/notes/implemented/architecture/2026-08-11-cmdline-seam-trim.zh.md)——缩减到既有接口的各 seam。 +- [dsh-app-boot](../app-boot/README.zh.md)——提供这些启动器值的启动序列。 +- [dsh-web-app 组合包](../../bundle/web-app/README.zh.md)——通过此包持有 Web flag 家族的应用。 +- [dsh-headless 组合包](../../bundle/headless/README.zh.md)——从命令行读取任务的一次性 runner。 + +----- + + ## 模型体验 -无。本包在任何会话存在之前解析进程自身的命令行。 +无。本包在任何会话存在之前解析进程自身的命令行;配置行持有每一个模型可见的后果。 #### KV Cache 影响 @@ -68,6 +127,25 @@ Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活 ## 已知限制与延期工作 -- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 -- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。 -- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 + + + +这些限制说明应用自有命令行在何时不合适,或何时需要特别注意。它们是当前包约束,不是任务积压。 + +- **启动器的 flag 必须写在应用参数之前**——切分按位置进行:启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 +- **应用自有服务没有静态声明的提供方**——消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。 +- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**——flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +本开发备注是维护者的工作上下文:开放设计问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文、包代码和相关 Agent Note 为准。 + +#### 待定:解析器表面 + +`parseCmdline` 是 commander 适配器,而不是命令行框架:help、version 与错误输出遵循 commander 的格式,退出/输出路由也假定 commander 的控制流模型。改用其他解析器需要它自己的路由与错误处理;`cmdlineArgs` 服务约定中没有任何内容依赖 commander。 + +
diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 6ec5f68a74..60c63d4ec4 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-cmdline", "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts index c053dcb95f..5e877f89e9 100644 --- a/packages/boot/cmdline/src/index.ts +++ b/packages/boot/cmdline/src/index.ts @@ -41,12 +41,25 @@ export interface AppExit { (code: number): void } +/** Successful application-startup signal owned by the launcher. */ +export interface AppReady { + /** + * Run a listener once successful startup is committed. A failed or + * externally terminated startup never calls it. + * @param listener - work that may begin only after successful startup. + * @returns a disposer that cancels a pending listener. + */ + onReady(listener: () => void): () => void +} + declare module '@deepseek-ai/cordis' { interface Context { /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ cmdlineArgs?: CmdlineArgs /** Bounded process-exit request; provided by a launcher before the tree mounts. */ appExit?: AppExit + /** Successful startup signal; provided by a launcher before the tree mounts. */ + appReady?: AppReady } } @@ -56,27 +69,81 @@ export interface CmdlineHost { args: readonly string[] /** Bounded process-exit request. */ exit: AppExit + /** Successful startup signal for lifecycle work that must not mask boot failure. */ + ready?: AppReady } /** - * Provide the command line and the exit request on a host context before any - * tree entry mounts. Both are launcher facts, not config: an embedding host - * with no command line provides an empty argument list. + * Provide launcher facts on a host context before any tree entry mounts: the + * command line, bounded exit request, and optional successful-startup signal. + * An embedding host with no command line provides an empty argument list; a + * host that mounts a stdio application also provides readiness. * @param ctx - the host context the tree will mount under. - * @param host - the invocation's arguments and its exit request. + * @param host - the invocation's arguments, exit request, and optional readiness signal. */ export function provideCmdline(ctx: Context, host: CmdlineHost): void { const snapshot: readonly string[] = Object.freeze([...host.args]) ctx.provide('cmdlineArgs', { get: () => snapshot }) ctx.provide('appExit', host.exit) + if (host.ready !== undefined) ctx.provide('appReady', host.ready) } -/** The process streams commander output is written to; production writes to the process. */ -export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { +/** Process stdin operations used to bind a stdio application's lifetime. */ +export interface AppStdin { + /** Whether EOF arrived before the application bound its listener. */ + readonly readableEnded: boolean + /** Subscribe once to stdin EOF. */ + once(event: 'end', listener: () => void): unknown + /** Remove a previously installed stdin EOF listener. */ + off(event: 'end', listener: () => void): unknown +} + +/** Process streams used by app command lines and stdio lifetime binding; tests substitute them. */ +export const internals: { + stdin: AppStdin + stdout: { write(chunk: string): unknown } + stderr: { write(chunk: string): unknown } +} = { + stdin: process.stdin, stdout: process.stdout, stderr: process.stderr, } +/** + * Make stdin EOF request the launcher's bounded successful shutdown after + * {@link AppReady} commits. A startup rejection therefore remains the process + * outcome when it races EOF. The caller invokes this only after its command + * action accepts the invocation, so help and usage failures start no transport + * lifecycle. This listener does not read or resume stdin: the protocol + * transport owns input and receives bytes buffered before it mounts. Disposal + * removes the EOF and readiness listeners. + * @param ctx - app plugin context carrying the launcher's exit request. + * @param label - effect label naming the owning application. + */ +export function exitOnStdinEnd(ctx: Context, label: string): void { + const exit = ctx.get('appExit') + const ready = ctx.get('appReady') + if (exit === undefined || ready === undefined) { + throw new Error('stdio app: the launcher must provide ctx.appExit and ctx.appReady before the tree mounts') + } + const stdin = internals.stdin + let active = true + let ended = false + let cancelReady = (): void => {} + const onEnd = (): void => { + if (!active || ended) return + ended = true + cancelReady = ready.onReady(() => { exit(0) }) + } + ctx.effect(() => () => { + active = false + cancelReady() + stdin.off('end', onEnd) + }, label) + stdin.once('end', onEnd) + if (stdin.readableEnded) queueMicrotask(onEnd) +} + /** * Parse the launcher's immutable argument snapshot with an app's commander * program. Commander runs the program's own synchronous action handler on a diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts index d05126a29f..6f41146c61 100644 --- a/packages/boot/cmdline/tests/cmdline.spec.ts +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -5,16 +5,18 @@ */ import { mkdtempSync, writeFileSync } from 'node:fs' +import { EventEmitter } from 'node:events' import { tmpdir } from 'node:os' import { join } from 'node:path' +import { PassThrough } from 'node:stream' import { pathToFileURL } from 'node:url' import { Command } from 'commander' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' -import { afterEach, describe, expect, it } from 'vitest' -import { internals, parseCmdline, provideCmdline } from '../src/index.ts' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { exitOnStdinEnd, internals, parseCmdline, provideCmdline, type AppReady } from '../src/index.ts' /** Every value one boot of the fixture tree observed. */ interface Observed { @@ -32,12 +34,46 @@ interface Fixture { const disposers: (() => Promise)[] = [] +const readyApp: AppReady = { + onReady(listener) { + listener() + return () => {} + }, +} + +function controlledAppReady(): { service: AppReady; commit(): void } { + const listeners = new Set<() => void>() + return { + service: { + onReady(listener) { + listeners.add(listener) + return () => { listeners.delete(listener) } + }, + }, + commit() { + for (const listener of [...listeners]) listener() + listeners.clear() + }, + } +} + afterEach(async () => { for (const dispose of disposers.splice(0)) await dispose() + internals.stdin = process.stdin internals.stdout = process.stdout internals.stderr = process.stderr }) +/** In-memory stdin whose end edge and ended-before-bind state are controllable. */ +class TestStdin extends EventEmitter { + readableEnded = false + + end(): void { + this.readableEnded = true + this.emit('end') + } +} + /** The fixture app's flag family: one `--port` its rows read from the service. */ function demoCommand(): Command { return new Command().name('demo').exitOverride().option('--port ', 'listen port') @@ -230,3 +266,101 @@ describe('provideCmdline', () => { expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) }) }) + +describe('exitOnStdinEnd', () => { + it('requests bounded exit on EOF and removes the listener on disposal', async () => { + const ctx = new Context() + const stdin = new TestStdin() + const exits: number[] = [] + internals.stdin = stdin + provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp }) + exitOnStdinEnd(ctx, 'test.stdin') + stdin.end() + expect(exits).toEqual([0]) + await ctx.fiber.dispose() + stdin.emit('end') + expect(exits).toEqual([0]) + }) + + it('requests exit after binding to stdin that has already ended', async () => { + const ctx = new Context() + const stdin = new TestStdin() + const exits: number[] = [] + stdin.readableEnded = true + internals.stdin = stdin + provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp }) + exitOnStdinEnd(ctx, 'test.stdin') + stdin.end() + await Promise.resolve() + expect(exits).toEqual([0]) + }) + + it('cancels an already-ended stream before its queued EOF handler runs', async () => { + const ctx = new Context() + const stdin = new TestStdin() + const exits: number[] = [] + let queued: (() => void) | undefined + const queue = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation((listener) => { queued = listener }) + stdin.readableEnded = true + internals.stdin = stdin + try { + provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp }) + exitOnStdinEnd(ctx, 'test.stdin') + await ctx.fiber.dispose() + queued?.() + expect(exits).toEqual([]) + } finally { + queue.mockRestore() + } + }) + + it('leaves protocol bytes buffered until the transport claims stdin', async () => { + const ctx = new Context() + const stdin = new PassThrough() + const exits: number[] = [] + internals.stdin = stdin + provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: readyApp }) + exitOnStdinEnd(ctx, 'test.stdin') + + const frame = '{"jsonrpc":"2.0","id":1,"method":"initialize"}\n' + stdin.write(frame) + expect(stdin.readableFlowing).not.toBe(true) + let received = '' + stdin.on('data', (chunk: Buffer) => { received += chunk.toString('utf8') }) + const ended = new Promise((resolve) => { stdin.once('end', resolve) }) + stdin.end() + await ended + + expect(received).toBe(frame) + expect(exits).toEqual([0]) + await ctx.fiber.dispose() + }) + + it('waits for the launcher to commit successful startup after EOF', async () => { + const ctx = new Context() + const stdin = new TestStdin() + const exits: number[] = [] + const ready = controlledAppReady() + internals.stdin = stdin + provideCmdline(ctx, { args: [], exit: code => void exits.push(code), ready: ready.service }) + exitOnStdinEnd(ctx, 'test.stdin') + + stdin.end() + expect(exits).toEqual([]) + ready.commit() + expect(exits).toEqual([0]) + await ctx.fiber.dispose() + }) + + it('fails loud without a launcher exit request', () => { + internals.stdin = new TestStdin() + expect(() => { exitOnStdinEnd(new Context(), 'test.stdin') }).toThrow('launcher must provide ctx.appExit and ctx.appReady') + }) + + it('fails loud without launcher startup readiness', () => { + const ctx = new Context() + internals.stdin = new TestStdin() + provideCmdline(ctx, { args: [], exit: () => {} }) + expect(() => { exitOnStdinEnd(ctx, 'test.stdin') }).toThrow('launcher must provide ctx.appExit and ctx.appReady') + }) +}) diff --git a/packages/bundle/README.i18n.yaml b/packages/bundle/README.i18n.yaml index 0441ec7d87..a7ba321f44 100644 --- a/packages/bundle/README.i18n.yaml +++ b/packages/bundle/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/README.md -README.md: 4d7a064939ae04f25737b324ec35332b7b944f80 -README.zh.md: 8910b33a97acd2ef3ee5b659305739246004de01 +README.md: 6311b61d5e20a13d1f1ac67729ab5bc24f3ec902 +README.zh.md: cb7e27a47a708193198c4dc0c15f95cb7f907a1e diff --git a/packages/bundle/README.md b/packages/bundle/README.md index 4d7a064939..6311b61d5e 100644 --- a/packages/bundle/README.md +++ b/packages/bundle/README.md @@ -1,15 +1,45 @@ +--- +description: "Ready-made dsh profile bundles for the shared core, browser GUI, one-shot task, ACP, and SDK application surfaces." +kind: "package-group" +--- + # bundle/ — profile plugin bundles English | [中文](README.zh.md) -Profile bundles: npm packages whose manifest declares `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`, making them installable patch layers for `dsh --profile` compositions ([profile contract](../boot/app-boot/README.md#profiles)). A bundle's substance is its patch list; some also ship runtime glue plugins their patch mounts. +## Summary -The manifest declaration, not this directory, defines Bundle identity. Domain packages can carry their own optional Profile layer; the [Codex and Claude Code subagent packages](../subagent/README.md) are directly installable examples. +This group maps the installable patch layers used by `dsh --profile`. Each package declares `dsh.bundle.patch`; the launcher stacks those patch documents to assemble a named profile. The `web`, `headless`, `acp`, and `sdk` profiles build on `dsh-base`, while `sdk-minimal` supplies its complete tree in one bundle. Domain packages can declare additional layers outside this directory. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + + +## Packages | Package | Role | ctx key | |---|---|---| -| [`base/`](base/README.md) | The shared dsh core every profile applies first | — (patch only) | -| [`web-app/`](web-app/README.md) | Browser surface: web patch layer + runtime glue plugin | mounts rows | -| [`headless/`](headless/README.md) | Direct one-shot task mode over base, with no Host or Web layer | mounts `headless-runner` | +| [`base`](base/README.md) | Shared core for base-backed profiles | — (patch only) | +| [`acp-app`](acp-app/README.md) | Automation-only ACP stdio application over base | mounts the ACP bridge | +| [`web-app`](web-app/README.md) | Browser application layer over base | mounts Web rows | +| [`headless`](headless/README.md) | One-shot command-line task application over base | `headless-runner` | +| [`sdk-app`](sdk-app/README.md) | SDK JSON-RPC stdio application over base | mounts the SDK server | +| [`sdk-minimal`](sdk-minimal/README.md) | Standalone minimal SDK application without base or Web | — (complete patch tree) | In-box bundles resolve from the dsh installation; out-of-tree bundles install into a profile through `dsh plugin --profile add `. + + +## Related documentation + +- [dsh app](../../apps/cli/README.md) — the `dsh` command that starts a profile. +- [app-boot](../boot/app-boot/README.md) — how profiles are resolved, layered, and customized. +- [Profile plugin bundles note](../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design. +- [Generated composition graph](../../apps/cli/composition.md) — the exact composition each shipped profile uses. + + +## Dev Note + +None. diff --git a/packages/bundle/README.zh.md b/packages/bundle/README.zh.md index 8910b33a97..cb7e27a47a 100644 --- a/packages/bundle/README.zh.md +++ b/packages/bundle/README.zh.md @@ -1,15 +1,45 @@ -# bundle/ — profile 插件组合包 +--- +description: "共享核心、浏览器 GUI、一次性任务、ACP 与 SDK 应用表层的现成 dsh profile bundle。" +kind: "package-group" +--- + +# bundle/:profile 插件组合包 [English](README.md) | 中文 -Profile 组合包:在 manifest(元数据清单)中声明 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包,因此可作为 patch 层安装进 `dsh --profile` 组合([profile 约定](../boot/app-boot/README.md#profiles))。组合包的实体是它的 patch 列表;有些组合包还附带由其 patch 挂载的运行时粘合插件。 +## 概述 -Bundle 身份由 manifest 声明决定,而不是由本目录决定。领域包可以携带自己的可选 Profile 层;[Codex 与 Claude Code subagent 包](../subagent/README.md)就是可直接安装的例子。 +本组列出 `dsh --profile` 使用的可安装 patch 层。每个包都声明 `dsh.bundle.patch`;启动器会叠放这些 patch 文档来组装具名 profile。`web`、`headless`、`acp` 与 `sdk` profile 以 `dsh-base` 为基础,`sdk-minimal` 则由一个 bundle 提供完整配置树。领域包也可以在本目录之外声明附加层。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + + +## 包 | 包 | 职责 | ctx key | |---|---|---| -| [`base/`](base/README.md) | 每个 profile 最先应用的共享 dsh 核心 | —(仅 patch) | -| [`web-app/`](web-app/README.md) | 浏览器表层:web patch 层 + 运行时粘合插件 | 挂载多条配置行 | -| [`headless/`](headless/README.md) | 直接运行在 base 之上的一次性任务模式,不含 Host 或 Web 层 | 挂载 `headless-runner` | +| [`base`](base/README.zh.md) | 基于 base 的 profile 共享核心 | —(仅 patch) | +| [`acp-app`](acp-app/README.zh.md) | 基于 base 的纯自动化 ACP stdio 应用 | 挂载 ACP bridge | +| [`web-app`](web-app/README.zh.md) | 基于 base 的浏览器应用层 | 挂载 Web 配置项 | +| [`headless`](headless/README.zh.md) | 基于 base 的一次性命令行任务应用 | `headless-runner` | +| [`sdk-app`](sdk-app/README.zh.md) | 基于 base 的 SDK JSON-RPC stdio 应用 | 挂载 SDK server | +| [`sdk-minimal`](sdk-minimal/README.zh.md) | 不使用 base 或 Web 的独立极简 SDK 应用 | —(完整 patch 树) | 内置组合包从 dsh 安装目录解析;树外(out-of-tree)组合包通过 `dsh plugin --profile add ` 安装进 profile。 + + +## 相关文档 + +- [dsh 应用](../../apps/cli/README.zh.md)——启动 profile 的 `dsh` 命令。 +- [app-boot](../boot/app-boot/README.zh.md)——profile 如何解析、分层与定制。 +- [Profile 组合包设计笔记](../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包的组合设计。 +- [生成组合图](../../apps/cli/composition.md)——每个已发布 profile 使用的确切组合。 + + +## 开发备注 + +无。 diff --git a/packages/bundle/acp-app/README.i18n.yaml b/packages/bundle/acp-app/README.i18n.yaml new file mode 100644 index 0000000000..3f7399f380 --- /dev/null +++ b/packages/bundle/acp-app/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bundle/acp-app/README.md +README.md: 90846589d3ffa4eae71bc892251b07b69d96ce87 +README.zh.md: 0669e080e05c62f877e970e54d9f0b8ea946c4cc diff --git a/packages/bundle/acp-app/README.md b/packages/bundle/acp-app/README.md new file mode 100644 index 0000000000..90846589d3 --- /dev/null +++ b/packages/bundle/acp-app/README.md @@ -0,0 +1,74 @@ +--- +description: "Automation-only ACP stdio application profile for users and maintainers launching persistent harness agents." +kind: "package-bundle" +--- + +# `@deepseek-ai/dsh-acp-app` + +English | [中文](README.zh.md) + +## Summary + +The automation-only ACP stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona and default model route, mounts an app-owned zero-option command provider, and starts [`dsh-acp`](../../acp/acp/README.md) only after that provider accepts the invocation. `dsh --profile acp --help` therefore writes help and exits without claiming stdin or stdout. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Standard automation workflow](#standard-automation-workflow) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The startup provider binds stdin EOF to the launcher's bounded successful shutdown. ACP connection close, SIGINT, and SIGTERM drain the bridge-owned agents and the root profile tree before exit. Stdout is reserved for newline-delimited ACP JSON-RPC frames. The bundle disables model-generated session titles because ACP exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints ACP-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin. + +The shipped row creates sessions with `deepseek-official` and `deepseek-v4-flash`; a later patch can replace that row's complete config. The base profile owns adapters, tools, persistence, policy, settings, credentials, and the per-session workspace supplied by the ACP client. + +----- + + +## Standard automation workflow + +An ACP v1 SDK client initializes `dsh --profile acp`, creates a session with an absolute `cwd` and optional standard stdio/HTTP MCP declarations, chooses an advertised `model` or `reasoning_effort`, prompts while observing standard semantic updates, then calls `session/close`. Another process can use `session/list` and `session/resume` against the same profile persistence root; resume reconnects the MCP declarations supplied by that request and does not replay history. + +The complete supported method matrix, MCP trust model, update mapping, and stop reasons live in the [`dsh-acp` protocol contract](../../acp/acp/README.md#standard-acp-v1-surface). This profile adds no private method, capability, `_meta`, environment variable, or transport field. The keyless control-surface conformance test drives the real profile through the public ACP SDK. + + +## Model Experience + +### ACP coding-agent persona + +#### What the model sees + +The profile supplies `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.` before the base tool and context contributions. The ACP row's route and each `session/new` cwd resolve the placeholders. + +#### Token effect + +One short stable persona plus the data-dependent base prompt sections and selected tool schemas. + +#### KV Cache effect + +Stable for a fixed profile, provider, model, and tool roster. Profile changes take effect on the next process because the shipped ACP profile uses startup-only patches. + +## Known Limitations and Deferred Work + + + +- **A profile can omit the ACP bridge** — a custom ACP launch profile must retain this bundle or another `dsh-acp` row; otherwise no peer answers the client. +- **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin. +- **Configuration changes require restart** — the shipped `acp` profile uses `patchReload: startup` so one stdio connection never observes a replacement bridge or Agent dependency. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/bundle/acp-app/README.zh.md b/packages/bundle/acp-app/README.zh.md new file mode 100644 index 0000000000..0669e080e0 --- /dev/null +++ b/packages/bundle/acp-app/README.zh.md @@ -0,0 +1,74 @@ +--- +description: "面向启动持久 harness agent 的用户与维护者,说明纯自动化 ACP stdio 应用 profile。" +kind: "package-bundle" +--- + +# `@deepseek-ai/dsh-acp-app` + +[English](README.md) | 中文 + +## 概述 + +以 [`dsh-base`](../base/README.zh.md) 为基础的 automation-only ACP stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona 与默认模型路由、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-acp`](../../acp/acp/README.zh.md)。因此,`dsh --profile acp --help` 会写出 help 并退出,不会占用 stdin 或 stdout。 + +## 目录 + +- [使用本包](#use-this-package) +- [标准自动化工作流](#standard-automation-workflow) +- [模型体验](#model-experience) +- [已知限制与待办事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +启动提供方把 stdin EOF 绑定到启动器的有界成功关闭。ACP 连接关闭、SIGINT 与 SIGTERM 会在退出前排空 bridge 自有 agent 以及根 profile 树。Stdout 仅保留给换行分隔的 ACP JSON-RPC frame。ACP 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 ACP 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署方通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个 app bin。 + +随附配置项使用 `deepseek-official` 与 `deepseek-v4-flash` 创建 session;后续 patch 可以替换该配置项的完整 config。base profile 负责适配器、工具、持久化、策略、settings 与 credentials;ACP client 为每个 session 提供工作区。 + +----- + + +## 标准自动化工作流 + +ACP v1 SDK 客户端先初始化 `dsh --profile acp`,再用绝对 `cwd` 与可选的标准 stdio/HTTP MCP 声明创建 session,选择公开的 `model` 或 `reasoning_effort`,在观察标准语义更新的同时提交提示词,最后调用 `session/close`。另一个进程可以针对同一个 profile 持久化根目录使用 `session/list` 与 `session/resume`;resume 会重新连接该请求提供的 MCP 声明,但不会重放历史。 + +完整的受支持方法矩阵、MCP 信任模型、更新映射与停止原因见 [`dsh-acp` 协议约定](../../acp/acp/README.zh.md#standard-acp-v1-surface)。该 profile 不增加私有方法、能力、`_meta`、环境变量或传输字段。免密钥控制面一致性测试通过公开 ACP SDK 驱动真实 profile。 + + +## 模型体验 + +### ACP coding-agent persona + +#### 模型看到什么 + +在 base 的工具和上下文贡献之前,profile 提供 `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.`。ACP 配置项的路由与每个 `session/new` 的 cwd 会解析其中的占位符。 + +#### Token 影响 + +一段简短稳定的 persona,加上随数据变化的 base prompt section 与已选工具 schema。 + +#### KV Cache 影响 + +固定 profile、提供方、模型与工具集合下保持稳定。随附 ACP profile 只在启动时加载 patch,因此 profile 更改会在下一个进程生效。 + +## 已知限制与待办事项 + + + +- **profile 可以省略 ACP bridge**:自定义 ACP 启动 profile 必须保留本组合包或另一个 `dsh-acp` 配置项;否则没有 peer 响应 client。 +- **用户插件可能破坏 stdout 纯净性**:profile 与单次启动 patch 属于受信任的应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入的插件。 +- **配置更改需要重启**:随附 `acp` profile 使用 `patchReload: startup`,确保一条 stdio 连接不会观察到 bridge 或 Agent 依赖被替换。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/bundle/acp-app/cordis.patch.yml b/packages/bundle/acp-app/cordis.patch.yml new file mode 100644 index 0000000000..c1244f3912 --- /dev/null +++ b/packages/bundle/acp-app/cordis.patch.yml @@ -0,0 +1,20 @@ +# The automation-only ACP application over dsh-base. Stdout belongs to ACP. + +- id: system-prompt + config: + persona: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: session-title-llm + disabled: true + +- insert: + - id: acp-app-startup + name: '@deepseek-ai/dsh-acp-app' + + - id: acp + name: '@deepseek-ai/dsh-acp' + inject: [acpAppStartup] + config: + provider: deepseek-official + model: deepseek-v4-flash diff --git a/packages/bundle/acp-app/package.json b/packages/bundle/acp-app/package.json new file mode 100644 index 0000000000..5113761d29 --- /dev/null +++ b/packages/bundle/acp-app/package.json @@ -0,0 +1,55 @@ +{ + "name": "@deepseek-ai/dsh-acp-app", + "description": "The dsh ACP profile bundle: automation-only JSON-RPC stdio and process lifecycle over dsh-base", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/acp-app" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", + "commander": "^15.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/bundle/acp-app/src/index.ts b/packages/bundle/acp-app/src/index.ts new file mode 100644 index 0000000000..5665039bc8 --- /dev/null +++ b/packages/bundle/acp-app/src/index.ts @@ -0,0 +1,48 @@ +/** + * The ACP profile's command-line and stdin-lifetime provider. A successful + * parse publishes {@link ACP_APP_STARTUP_SERVICE}; the ACP bridge waits for + * that service, so help starts no transport. + * @module @deepseek-ai/dsh-acp-app + */ + +import { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +import { exitOnStdinEnd, parseCmdline } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'acp-app-startup' + +/** Launcher service required before this app can parse its invocation. */ +export const inject = ['cmdlineArgs'] + +/** Service the ACP bridge row waits for before claiming stdio. */ +export const ACP_APP_STARTUP_SERVICE = 'acpAppStartup' + +/** + * Build this app's zero-option command and help. + * @returns a fresh program for one invocation. + */ +function acpCommand(): Command { + return new Command() + .name('dsh --profile acp') + .description('Serve automation clients over Agent Client Protocol stdio.') + .helpOption('-h, --help', 'show this help') + .addHelpText('after', ` +Example: + dsh --profile acp serve ACP until the client disconnects +`) +} + +/** + * Accept an ACP profile invocation, publish readiness, and bind EOF to the + * launcher's bounded shutdown. + * @param ctx - plugin context carrying command-line and exit launcher values. + */ +export function apply(ctx: Context): void { + const program = acpCommand() + program.action(() => { + exitOnStdinEnd(ctx, 'acp-app.stdin') + ctx.provide(ACP_APP_STARTUP_SERVICE, { accepted: true }) + }) + parseCmdline(ctx, program) +} diff --git a/packages/bundle/acp-app/src/invariant.ts b/packages/bundle/acp-app/src/invariant.ts new file mode 100644 index 0000000000..96099709a9 --- /dev/null +++ b/packages/bundle/acp-app/src/invariant.ts @@ -0,0 +1,28 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-acp-app`. + * @module @deepseek-ai/dsh-acp-app/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-acp-app' + +/** Cordis companion plugin name. */ +export const name = 'acp-app-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the bundle adds a process transport and startup latch; + * source/built stdio tests own frame purity, help exclusion, and shutdown. + */ +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)) diff --git a/packages/bundle/acp-app/tests/acp-app.spec.ts b/packages/bundle/acp-app/tests/acp-app.spec.ts new file mode 100644 index 0000000000..40540e0964 --- /dev/null +++ b/packages/bundle/acp-app/tests/acp-app.spec.ts @@ -0,0 +1,36 @@ +/** The ACP app bundle's declared profile patch. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('dsh-acp-app bundle', () => { + it('declares startup-gated ACP serving without overriding base HMR policy', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-acp') + const patches = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) as Array<{ + id?: string + disabled?: boolean + insert?: Array<{ config?: { model?: string; provider?: string }; id?: string; inject?: string[]; name?: string }> + }> + expect(patches.find(patch => patch.id === 'hmr')).toBeUndefined() + expect(patches.find(patch => patch.id === 'session-title-llm')).toMatchObject({ disabled: true }) + const rows = patches.flatMap(patch => patch.insert ?? []) + expect(rows.find(row => row.id === 'acp-app-startup')?.name).toBe('@deepseek-ai/dsh-acp-app') + expect(rows.find(row => row.id === 'acp')).toMatchObject({ + inject: ['acpAppStartup'], + config: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + }) + }) +}) diff --git a/packages/bundle/acp-app/tests/startup.spec.ts b/packages/bundle/acp-app/tests/startup.spec.ts new file mode 100644 index 0000000000..75613cd1e9 --- /dev/null +++ b/packages/bundle/acp-app/tests/startup.spec.ts @@ -0,0 +1,65 @@ +/** The ACP app command provider and stdin shutdown binding. */ + +import { EventEmitter } from 'node:events' +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it } from 'vitest' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { ACP_APP_STARTUP_SERVICE, apply } from '../src/index.ts' + +/** Controllable stdin for one startup invocation. */ +class TestStdin extends EventEmitter { + readableEnded = false + + resume(): this { + return this + } + + end(): void { + this.readableEnded = true + this.emit('end') + } +} + +afterEach(() => { + internals.stdin = process.stdin + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** Run the provider with captured command output and exit requests. */ +function start(args: string[]): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { + const ctx = new Context() + const exits: number[] = [] + const stdin = new TestStdin() + let out = '' + const capture = { write: (chunk: string) => { out += chunk; return true } } + internals.stdin = stdin + internals.stdout = capture + internals.stderr = capture + provideCmdline(ctx, { + args, + exit: code => void exits.push(code), + ready: { onReady: (listener) => { listener(); return () => {} } }, + }) + apply(ctx) + return { ctx, exits, out: () => out, stdin } +} + +describe('ACP app startup', () => { + it('publishes readiness and requests bounded exit on client EOF', async () => { + const { ctx, exits, stdin } = start([]) + expect(ctx.get(ACP_APP_STARTUP_SERVICE)).toEqual({ accepted: true }) + stdin.end() + expect(exits).toEqual([0]) + await ctx.fiber.dispose() + }) + + it('prints app help without publishing readiness or binding stdin', () => { + const { ctx, exits, out, stdin } = start(['--help']) + expect(out()).toContain('dsh --profile acp') + expect(ctx.get(ACP_APP_STARTUP_SERVICE)).toBeUndefined() + expect(exits).toEqual([0]) + stdin.end() + expect(exits).toEqual([0]) + }) +}) diff --git a/packages/bundle/acp-app/tsconfig.json b/packages/bundle/acp-app/tsconfig.json new file mode 100644 index 0000000000..1d644141bd --- /dev/null +++ b/packages/bundle/acp-app/tsconfig.json @@ -0,0 +1,21 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../boot/cmdline" + } + ] +} diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 05ac3dfe47..21455662b4 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/base/README.md -README.md: 8487426ee7bf1b39a79b4e80b9c7bd661f317998 -README.zh.md: 797968d362cd35d1ba04087b30d8d02d1b537b44 +README.md: 4fc828a5614245bf89867e2c52d1b2019e300921 +README.zh.md: 0d80938229624dd98536bf4798e1541fd84bd8e1 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 8487426ee7..4fc828a561 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -1,22 +1,137 @@ -# `@deepseek-ai/dsh-base` +--- +description: "The shared dsh core: model access, tools, durable sessions, and safety defaults for every dsh --profile surface, for users composing or customizing a profile." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-base English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and the core spawn/fork subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. The optional Codex and Claude Code providers stay outside this package and its production dependency closure; a Profile installs either [product provider Bundle](../../subagent/README.md) only when needed. The default `@deepseek-ai/dsh` production closure therefore includes neither product provider, the Claude Agent SDK, nor the Codex wrapper and platform payloads. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +## Summary -The patch gates both shell stacks by platform on its own rows: `bash-sandbox`/`tool-bash` carry `disabled: !!js process.platform === 'win32'` (bash has no Windows runner), and their twins `pwsh-sandbox`/`tool-pwsh` mount on win32 only with the inverted expression — one shared patch file, exactly one shell stack per host. The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. A Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts see the pwsh rows disabled. +Every base-backed `dsh --profile` surface runs on `dsh-base`, so those surfaces share a model connection, the full tool set, durable session history, and workspace safety defaults. The shipped `sdk-minimal` profile deliberately uses a complete standalone tree instead. You rarely touch this bundle directly — shipped base-backed profiles already include it, and a custom base-backed profile names it first. When you need different defaults, change your profile patch or add a later bundle; this package is not a library you import. -The row set and its rationale are documented inline in the patch file; the [generated composition graph](../../../apps/cli/composition.md) renders it. +## Table of Contents +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +You get the dsh core automatically: the shipped `web` and `headless` profiles already include it, and a custom profile names it as its first bundle. After that, everything works with no further configuration. + +### A minimal custom profile + +To build a profile on the shared core, create a profile with a `package.json` that names `@deepseek-ai/dsh-base` first: + +```json +{ + "name": "my-profile", + "private": true, + "dsh": { + "profile": { + "bundles": ["@deepseek-ai/dsh-base"] + } + } +} +``` + +Run `dsh --profile my-profile "your task"` and you get a working agent with model access, tools, persistence, and the default permission policy. The shipped `web` and `headless` profiles are created for you on first use. To add more bundles, run `dsh plugin --profile add `; in-box bundles resolve from the dsh installation. The profile contract is documented in the [app-boot profile section](../../boot/app-boot/README.md). + +### What you get + +Out of the box, every profile built on this core provides: a DeepSeek model connection (the provider and model are configurable, and you can enable extra providers from your settings), the full tool set — file editing, shell commands, web search, subagents, task and goal tracking — durable sessions that survive restarts, and the default permission policy that confines file writes to your workspace and asks before risky actions. Telemetry stays off unless you opt in. + +### Shell tools per platform + +On macOS and Linux you get the bash shell tools; on Windows you get the PowerShell twins instead, so exactly one shell stack is available per machine. The safety behavior is identical on every platform. A Windows host that prefers the unconfined PowerShell executor can switch the shell rows in its profile patch — the switch must disable both PowerShell rows and re-enable both bash rows, otherwise the profile fails to load. + +### Changing the defaults + +To change what a profile built on this core provides — a different default model, a stricter permission mode, extra or fewer tools — edit your profile's `cordis.patch.yml` or add a later bundle. Each patch entry replaces the target's whole configuration, so restate every setting you want to keep. Keep the sandboxed filesystem provider as the single file-write path: adding the plain filesystem provider on top of it makes the profile fail to load. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The bundle is a static patch document: one `insert` list applied over the empty profile root. It mounts no service, emits no events, and holds no mutable state; each inserted row's package owns that row's behavior and invariants. + +### Composition mechanics + +A patch replaces the targeted row's whole `config` rather than merging into it. Later bundle layers and the user's profile `cordis.patch.yml` override rows by id, with the last write winning per row. Rows whose value differs by mode do not live here: each mode bundle restates its complete configuration, keeping any single row down to one bundle layer plus the user's. The full row set and its rationale are documented inline in [`cordis.patch.yml`](cordis.patch.yml); the [generated composition graph](../../../apps/cli/composition.md) renders it. + +### Platform gating + +The patch gates the two shell stacks by platform on its own rows: `bash-sandbox` and `tool-bash` carry `disabled: !!js process.platform === 'win32'`, and their twins `pwsh-sandbox` and `tool-pwsh` mount on win32 only with the inverted expression. The permission surface stays identical to POSIX: the sandbox policy executes the same file-effect policy through the Windows ACL restricted-token runner (`dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. + +### Source map + +| File | Role | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | The bundle substance: the base plugin rows, with per-row rationale as inline comments | +| [`src/index.ts`](src/index.ts) | Package entry; carries no runtime API | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no runtime invariant; each inserted row's package owns its invariants | +| [`tests/base.spec.ts`](tests/base.spec.ts) | Manifest declaration and platform-gating checks | + +### Invariant ownership + +The invariant companion registers an empty installer because the package is a static patch-list carrier: each inserted row's own package carries that row's invariants, and the bundle owns no mutable relation to check. + +
+ +----- + + +## Further Exploration + +Read these pages when you want to go deeper into profiles, the surfaces built on this core, or the exact composition. + +- [app-boot profile section](../../boot/app-boot/README.md) — how profiles are resolved, layered, and customized. +- [Bundle package map](../README.md) — the surfaces built on this core. +- [Generated composition graph](../../../apps/cli/composition.md) — the exact plugin set each shipped profile uses. +- [Profile plugin bundles note](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md) — the profile and bundle composition design. +- [Codex and Claude Code provider bundles](../../subagent/README.md) — optional provider bundles you can install on top. + +----- + + ## Model Experience -Indirectly, through the inserted rows: this bundle selects the shipped persona-less prompt base, tool set, and DeepSeek adapter that mode bundles specialize, and contributes no model-visible text of its own. +Indirectly, through each inserted row's package, which owns that row's model-facing behavior. #### KV Cache effect -None directly; each inserted row's package owns its effect. +The bundle itself adds no request prefix; each inserted row's package owns any cache effect. ## Known Limitations and Deferred Work -- **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. -- **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. + + + +These limits tell you when the core needs extra care or where an override must go. They are current package constraints, not a general comparison or a task backlog. + +- **Overrides replace whole settings blocks** — a patch entry replaces the target's entire configuration, so your override must restate every setting you want to keep; nothing merges automatically. +- **Per-surface settings belong to the surface's bundle** — a default that differs between the web GUI and headless mode lives in that surface's bundle, not in the shared core. +- **Windows temp grants are private per-session subdirectories** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. +- **Adding the plain filesystem provider on top of the sandboxed one fails the profile** — the two register the same service, so the profile refuses to load; use one or the other. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index 797968d362..0d80938229 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -1,22 +1,137 @@ -# `@deepseek-ai/dsh-base` +--- +description: "共享的 dsh 核心:为每个 dsh --profile 表层提供模型访问、工具、持久会话与安全默认值,供用户组合或定制 profile。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-base [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与核心 spawn/fork subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。可选的 Codex 与 Claude Code provider 不属于本包及其生产依赖闭包;Profile 仅在需要时安装任一[产品 provider Bundle](../../subagent/README.md)。因此,默认的 `@deepseek-ai/dsh` 生产依赖闭包既不包含任一产品 provider、Claude Agent SDK,也不包含 Codex wrapper 及其平台载荷。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +## 概述 -patch 在自身上按平台门控两个 shell 栈:`bash-sandbox`/`tool-bash` 携带 `disabled: !!js process.platform === 'win32'`(bash 没有 Windows runner),它们的孪生行 `pwsh-sandbox`/`tool-pwsh` 以取反的表达式仅在 win32 挂载——同一份 patch 文件,每个宿主恰好挂载一个 shell 栈。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。偏好不受沙盒约束的本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时直接报错)。POSIX 主机看到的是被禁用的 pwsh 行。 +每个基于 base 的 `dsh --profile` 表层都运行在 `dsh-base` 上,因此这些表层共享模型连接、完整工具集、持久会话历史和 workspace 安全默认值。随附的 `sdk-minimal` profile 刻意改用完整的独立配置树。你通常不直接操作本 bundle——随附的 base-backed profile 已经包含它,自定义 base-backed profile 则把它放在第一位。需要其他默认值时,应修改自己的 profile patch 或添加后续 bundle;本包不是供导入的库。 -行集合及其设计依据以行内注释写在 patch 文件里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 +## 目录 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +你会自动获得 dsh 核心:随发行版交付的 `web` 与 `headless` profile 已包含它,自定义 profile 则把它列为第一个组合包。之后一切无需任何额外配置即可工作。 + +### 最小自定义 profile + +要在共享核心之上构建 profile,请创建一个 profile,其 `package.json` 把 `@deepseek-ai/dsh-base` 列在首位: + +```json +{ + "name": "my-profile", + "private": true, + "dsh": { + "profile": { + "bundles": ["@deepseek-ai/dsh-base"] + } + } +} +``` + +运行 `dsh --profile my-profile "your task"`,你就得到一个可用的 agent(智能体),带模型访问、工具、持久化与默认权限策略。随发行版交付的 `web` 与 `headless` profile 会在首次使用时为你创建。要添加更多组合包,运行 `dsh plugin --profile add `;内置组合包从 dsh 安装目录解析。profile 约定见 [app-boot 的 profile 章节](../../boot/app-boot/README.zh.md)。 + +### 你得到什么 + +开箱即用,基于本核心构建的每个 profile 都提供:DeepSeek 模型连接(provider 与模型可配置,你还可以在设置中启用额外 provider)、完整工具集——文件编辑、shell 命令、web 搜索、subagent、任务与目标跟踪——可跨重启存活的持久会话,以及默认权限策略:把文件写入限制在工作区内,危险操作前征询许可。遥测默认关闭,除非你主动开启。 + +### 各平台的 shell 工具 + +在 macOS 与 Linux 上你获得 bash shell 工具;在 Windows 上则获得对应的 PowerShell 孪生工具,因此每台机器恰好有一套 shell 栈。各平台的安全行为完全一致。偏好不受沙盒约束的 PowerShell 执行器的 Windows 主机可以在其 profile patch 中切换 shell 行——切换必须同时禁用两个 PowerShell 行并重新启用两个 bash 行,否则 profile 无法加载。 + +### 更改默认值 + +要改变基于本核心构建的 profile 提供的内容——不同的默认模型、更严格的权限模式、更多或更少的工具——请编辑 profile 的 `cordis.patch.yml` 或添加后面的组合包。每个 patch 条目会替换目标的整个配置,因此请重述每个想保留的设置。保持沙箱化文件系统提供方作为唯一的文件写入路径:在其之上再添加普通文件系统提供方会导致 profile 加载失败。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本组合包是一份静态 patch 文档:一个应用到空 profile 根之上的 `insert` 列表。它不挂载任何服务、不发出任何事件、也不持有任何可变状态;每条插入行所属的包负责该行的行为与不变式。 + +### 组合机制 + +patch 会替换目标行的整个 `config`,而不是合并进它。后续组合包层与用户的 profile `cordis.patch.yml` 按 id 覆盖行,每行最后一次写入生效。按模式取值不同的行不属于这里:每个模式组合包重述自己的完整配置,让任何单一行最多只属于一个组合包层加用户层。完整行集合及其设计依据以行内注释写在 [`cordis.patch.yml`](cordis.patch.yml) 里;[生成的组合图](../../../apps/cli/composition.md)负责渲染它。 + +### 平台门控 + +patch 在自身上按平台门控两个 shell 栈:`bash-sandbox` 与 `tool-bash` 携带 `disabled: !!js process.platform === 'win32'`,孪生行 `pwsh-sandbox` 与 `tool-pwsh` 以取反的表达式仅在 win32 挂载。权限面与 POSIX 完全一致:沙箱策略通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`)执行相同的文件效果策略,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | 组合包的实体:基础插件行,附以行内注释说明各行依据 | +| [`src/index.ts`](src/index.ts) | 包入口;不携带任何运行时 API | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:无运行时不变式;每条插入行所属的包负责自己的不变式 | +| [`tests/base.spec.ts`](tests/base.spec.ts) | manifest 声明与平台门控检查 | + +### 不变式归属 + +不变式伴生插件注册一个空安装器,因为本包是静态 patch 列表载体:每条插入行由所属的包携带其不变式,组合包自身没有任何可审计的可变关系。 + +
+ +----- + + +## 进一步探索 + +当你想深入了解 profile、基于本核心构建的表层或确切组合时,阅读以下页面。 + +- [app-boot 的 profile 章节](../../boot/app-boot/README.zh.md)——profile 如何解析、分层与定制。 +- [组合包包映射](../README.zh.md)——基于本核心构建的表层。 +- [生成组合图](../../../apps/cli/composition.md)——每个已发布 profile 使用的确切插件集合。 +- [Profile 组合包设计笔记](../../../.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md)——profile 与组合包的组合设计。 +- [Codex 与 Claude Code provider 组合包](../../subagent/README.zh.md)——可叠加安装的可选 provider 组合包。 + +----- + + ## 模型体验 -通过插入的行间接产生影响:该组合包选定了随发行版交付的无 persona 提示词基座、工具集合与 DeepSeek 适配器,供各模式组合包进一步特化;它自身不贡献任何模型可见文本。 +通过每条插入行所属的包间接产生影响,由各包负责其行的模型可见行为。 #### KV Cache 影响 -无直接影响;每条插入行的影响由其所属的包负责。 +组合包本身不添加任何请求前缀;每条插入行所属的包负责各自的缓存影响。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 + + + +这些限制告诉你核心何时需要额外注意、覆盖应放在哪里。它们是当前包约束,不是通用对比或任务积压。 + +- **覆盖会替换整个设置块**——patch 条目会替换目标的整个配置,因此你的覆盖必须重述每个想保留的设置;不会自动合并。 +- **按表层的设置属于该表层的组合包**——web GUI 与 headless 模式取值不同的默认值放在对应表层的组合包里,而不是共享核心。 - **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何临时目录写入权限。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 +- **在沙箱化文件系统提供方之上添加普通提供方会导致 profile 失败**——两者注册同一个服务,profile 因此拒绝加载;二选一。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index e9567d9206..ae799992b8 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -1,4 +1,4 @@ -# The dsh-base bundle patch: the shared core of every dsh profile, applied as +# The dsh-base bundle patch: the shared core of each base-backed profile, applied as # ONE insert over the empty profile root. Later bundle patches and the user's # profile cordis.patch.yml address these rows by id, with the last write # winning per row. @@ -16,17 +16,26 @@ - id: timer name: '@deepseek-ai/cordis-plugin-timer' + # Module reload is opt-in per profile. `patchReload: live` config watching + # uses the launcher's watch-only fallback and does not require this row. - id: hmr name: '@deepseek-ai/cordis-plugin-hmr' + disabled: true config: root: ['.'] - id: llm name: '@deepseek-ai/dsh-llm' + - id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + - id: session name: '@deepseek-ai/dsh-session' + - id: session-log-deepseek + name: '@deepseek-ai/dsh-session-log-deepseek' + - id: typert name: '@deepseek-ai/dsh-typert-registry' @@ -58,6 +67,9 @@ - id: agent name: '@deepseek-ai/dsh-agent' + - id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + # The transport-independent default for Agents created by entry points. # Settings may supply a saved selection; consumers read it at creation time. - id: agent-default-model @@ -126,6 +138,33 @@ - id: session-projection name: '@deepseek-ai/dsh-session-projection' + # Durable KV storage: the storage hub, the json backend, and the + # schema-validated domain form over them. Session-layer persistence (the + # projection cache below; workspace and message-feedback in web layers) + # routes through this stack, so it belongs to the shared base. + - id: storage + name: '@deepseek-ai/dsh-storage' + + - id: storage-json + name: '@deepseek-ai/dsh-storage-json' + config: + root: !!js dshHomePath('storages') + + - id: storage-domain + name: '@deepseek-ai/dsh-storage-domain' + config: + backend: json + + # Persisted projection cache: throttled write-behind over the + # session_projcache domain (per-record layout — one version-stamped + # checkpoint document per session), serving the session listing's + # projection column. + - id: session-projection-cache + name: '@deepseek-ai/dsh-session-projection-cache' + config: + writeEveryEvents: 200 + writeIntervalMs: 5000 + # Session telemetry is mounted but disabled by default. DSH_TELEMETRY_MODE # explicitly opts into FULL or FEEDBACK_ONLY reporting; uploading mirrors # session-log records onto OTLP/HTTP logs with no session-telemetry/record redaction @@ -315,12 +354,15 @@ config: provider: spawn toolName: subagent + enableModelSelection: true backgroundMode: continuable - # Fork stays one-shot: a continuable child's `report` tool and prompt - # section precede the inherited history a fork exists to reuse; one-shot - # fork children install neither, keeping the parent's request prefix. - # See .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. + # Fork omits model selection so provider/model stay equal to the parent and + # the inherited history remains eligible for KV Cache reuse. It stays one-shot + # because a continuable child's `report` tool and prompt section precede that + # history and invalidate the same prefix. + # See .agents/notes/implemented/feature/2026-08-18-model-selected-subagent-routes.md + # and .agents/notes/implemented/architecture/2026-08-10-fork-children-stay-one-shot.md. - id: tool-subagent-fork name: '@deepseek-ai/dsh-tool-subagent' config: @@ -393,24 +435,30 @@ thresholds: [3, 5, 8] argumentsPreviewChars: 500 - # Every mode enables the stable model-facing web_search tool. DeepSeek search - # resolves the same DEEPSEEK_API_KEY credential the Models page manages for - # chat, at each search; its Messages endpoint is separate from the - # chat-completions endpoint, so it takes its own base-URL override. Fetch stays - # disabled and no fetch provider is mounted: that provider defers SSRF - # protection and the model would choose the request target. Search is a full - # auxiliary model request with server-side retrieval, so this shipped DeepSeek - # route gets 60s while the provider-neutral tool default remains 30s. + # Every mode enables the stable model-facing web_search tool. The Web app's + # per-agent presets additionally enable web_fetch; other products opt in by + # overriding tool-web. DeepSeek search resolves the same DEEPSEEK_API_KEY + # credential the Models page manages for chat, at each search; its Messages + # endpoint is separate from the chat-completions endpoint, so it takes its own + # base-URL override. Anonymous fetch accepts only public HTTP(S) destinations, + # resolves and validates every destination, and pins every actual connection. + # Search is a full auxiliary model request with server-side retrieval, so this + # shipped DeepSeek route gets 60s while the provider-neutral tool default + # remains 30s. - id: web name: '@deepseek-ai/dsh-web' config: searchProvider: deepseek-official + fetchProvider: http - id: web-search-deepseek name: '@deepseek-ai/dsh-web-search-deepseek' config: apiKeyEnv: DEEPSEEK_API_KEY + - id: web-fetch-http + name: '@deepseek-ai/dsh-web-fetch-http' + - id: tool-web name: '@deepseek-ai/dsh-tool-web' config: diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 62350bbc11..82dcc1e007 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-base", - "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", - "version": "0.1.0-rc.7", + "description": "The shared dsh core as a profile bundle: the first patch layer of base-backed profiles, inserting core rows over the empty profile root", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -54,6 +54,8 @@ "@deepseek-ai/dsh-compaction-basic": "workspace:^", "@deepseek-ai/dsh-compaction-tool-result-pruner": "workspace:^", "@deepseek-ai/dsh-credentials-local": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-observation-policy": "workspace:^", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", @@ -72,8 +74,10 @@ "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", "@deepseek-ai/dsh-session-telemetry-otel": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", @@ -84,6 +88,9 @@ "@deepseek-ai/dsh-skill-filesystem": "workspace:^", "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-fork-in-process": "workspace:^", "@deepseek-ai/dsh-subagent-spawn-in-process": "workspace:^", @@ -113,6 +120,7 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-questions": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/dsh-web-fetch-http": "workspace:^", "@deepseek-ai/dsh-web-search-deepseek": "workspace:^", "@deepseek-ai/dsh-workflow-worker-thread": "workspace:^", "@deepseek-ai/dsh-agent-instructions": "workspace:^" diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index e70bc0ff74..00695bca1b 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -27,7 +27,7 @@ describe('dsh-base bundle', () => { ) expect(Array.isArray(parsed)).toBe(true) // The base layer is one insert list over the empty profile root. - const rows = (parsed as { insert?: { id?: string; config?: Record }[] }[]).flatMap( + const rows = (parsed as { insert?: { id?: string; config?: Record; disabled?: boolean }[] }[]).flatMap( patch => patch.insert ?? [], ) expect(rows.length).toBeGreaterThan(50) @@ -35,10 +35,18 @@ describe('dsh-base bundle', () => { expect(rows.find(row => row.id === 'session-telemetry-otel')?.config?.['mode']).toEqual({ __jsExpr: "process.env.DSH_TELEMETRY_MODE || 'DISABLED'", }) + expect(rows.find(row => row.id === 'hmr')).toMatchObject({ + disabled: true, + config: { root: ['.'] }, + }) expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(0) expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(0) + expect(rows.find(row => row.id === 'web')?.config).toMatchObject({ fetchProvider: 'http' }) + expect(rows.find(row => row.id === 'web-fetch-http')).toBeDefined() + expect(rows.find(row => row.id === 'tool-web')?.config).toMatchObject({ fetch: false }) expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-codex') expect(manifest.dependencies).not.toHaveProperty('@deepseek-ai/dsh-subagent-claude-code') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-web-fetch-http') }) it('gates each shell stack by platform with a symmetric disabled expression', () => { diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 06e8c0968c..fc0be1d54b 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/headless/README.md -README.md: 3d9ca350f5f8891e60cfc57c9ca89ef57d9790d3 -README.zh.md: a05019b8d01518a83a54f2637ff4daf80061c0ed +README.md: 952ae369e58b60389a951f2f48a080d735a3d9ad +README.zh.md: 8d9084550fd295c19b679b64cf17f0e2cdafbeb6 diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index 3d9ca350f5..952ae369e5 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -1,20 +1,136 @@ -# `@deepseek-ai/dsh-headless` +--- +description: "One-shot task mode for dsh: run a single task from the command line and get the final answer printed, for users scripting or automating dsh." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-headless English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. +## Summary -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.appExit` host hook ([`dsh-cmdline`](../../boot/cmdline/README.md)) (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. +`dsh-headless` runs one dsh task from the command line and prints the final answer, then exits — no GUI, no server, no browser. Type `dsh --profile headless "run the tests"` and the agent works through the task with the same model, tools, and safety defaults as every other surface. It is ideal for scripts, CI, and one-off jobs: the process opens no ports and leaves nothing running behind. The exit code tells you the outcome — 0 when the task completed, 1 when it aborted or errored. The main boundary: one task per invocation, with no interactive follow-up. +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Run one task, get the final answer, and exit. The task is the command line itself, so the whole invocation is the smallest working example. + +### Running a one-shot task + +```sh +dsh --profile headless "run the tests" +``` + +The agent works through the task, streams each non-empty provider reasoning delta to stderr under a `dsh: reasoning:` heading, then prints the final answer on stdout and exits. Consecutive reasoning deltas stay in one section, and the runner closes that section before later output when the provider supplied no trailing newline. A successful run without reasoning keeps stderr empty; a failure exits 1 and prints `dsh: : ` to stderr. A missing or blank task is rejected before anything runs. The task text is supplied through the single `task` setting: + +| Field | Default | Meaning | +|---|---|---| +| `task` | required | The task text for the single run | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-headless) is the exhaustive source for every accepted field and its JSDoc. + +### When to use it + +Use headless for scripted or automated dsh runs — CI steps, batch jobs, quick answers from a terminal. Avoid it when you need a multi-turn interactive session or a GUI; the browser surface ([dsh-web-app](../web-app/README.md)) serves that. The process stays alive only for the run, opens no listening port, and exits on its own, so it fits pipelines that wait on the process. + +### Help and task errors + +`dsh --profile headless --help` prints the command's help text and exits without running anything. A missing or whitespace-only task is a usage error: nothing runs and the process exits 1. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The runner is a direct driver over the core API carrier: it creates one fresh Agent through the registry and folds the owned durable event interval into one process-level outcome. + +### Run flow + +The runner awaits the complete application (`ctx.get('loader')?.await()`) so the composed tools and adapters are not half-mounted, reads the shared [`agentDefaultModel`](../../core/agent-default-model/README.md) selection, creates one fresh persisted Agent with that provider and model, and submits the task as an ordinary user message. It streams that Agent's non-empty reasoning deltas to stderr, waits for quiescence, then flushes the Session and folds the owned interval (`firstSeq` onward) into the last non-empty `assistant/message` text and final `turn/end` reason. It writes the final text to stdout and requests exit. + +### Patch surface over base + +The patch rides over `dsh-base`: it inherits the projection cache, sets the coding persona on the base `system-prompt` row, keeps the same temporary process-wide Code Mode opt-in (`DSH_TOOLS_MODE`) as the Web surface, disables the shared HMR row, inserts Code Mode's worker as a core execution capability, and mounts the startup provider and the runner. The cache checkpoints each persisted one-shot session for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. The startup provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. + +### Exit mapping + +A completed final `turn/end` exits 0; any other outcome — aborted, error, or no turn in the owned interval — exits 1. An `error` reason also writes `dsh: : ` to stderr. A direct driver failure (for example, Agent creation) writes `dsh: ` to stderr and exits 1. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | The `headless-runner` plugin: run flow, output contract, exit mapping | +| [`src/startup.ts`](src/startup.ts) | The `headless-startup` provider: task positional and `--help` | +| [`cordis.patch.yml`](cordis.patch.yml) | The one-shot patch over `dsh-base` | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no runtime invariant; the observable contract is process-level | +| [`tests/headless.spec.ts`](tests/headless.spec.ts) | Run flow, aggregation, flush, and exit mapping | +| [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | + +### Invariant ownership + +The invariant companion registers an empty installer because the runner's observable contract (final text on stdout, exit code by turn-end reason) is process-level and owned by the launcher e2e; the plugin registers nothing and holds no mutable relation to audit inside the tree. + +
+ +----- + + +## Further Exploration + +Read these pages when you want to go deeper into the shared core, the sibling GUI, or the command-line handoff. + +- [Bundle package map](../README.md) — the surfaces built on the same core. +- [dsh-base](../base/README.md) — the shared core headless runs on. +- [dsh-web-app](../web-app/README.md) — the interactive browser sibling for multi-turn work. +- [dsh-cmdline](../../boot/cmdline/README.md) — how the launcher hands the command line to the app. +- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-headless) — every accepted config field and its source declaration. + +----- + + ## Model Experience -None, as the runner submits the task as an ordinary user message; prompts and tools belong to the base and headless bundle rows. +None, as the runner submits the task as an ordinary user message and the composed base and headless rows own the prompts and tools. #### KV Cache effect -None; the runner adds nothing to the request prefix. +The runner adds nothing to the request prefix; it only drives one user message through the composed tree. ## Known Limitations and Deferred Work -- **One submitted task only** — the runner has no interactive follow-up surface; it waits through any work the Agent completes before returning to idle and prints the last non-empty assistant message in that interval. -- **`ctx.appExit` is launcher-owned** — booting the headless profile outside the `dsh` launcher fails loud at activation until the host provides the exit request. + + + +These limits tell you when headless does not fit and what it needs from the `dsh` launcher. They are current package constraints, not a general CLI comparison or a task backlog. + +- **One task per run** — after the task is answered the process exits; there is no interactive follow-up, so split multi-step work into separate runs. +- **Runs through the `dsh` launcher** — starting the headless profile another way fails at startup, because only the launcher can request the process exit. +- **No pre-token heartbeat** — stderr stays silent until the provider emits a non-empty reasoning delta; a delayed first token exposes no earlier progress signal. +- **Reasoning enters stderr logs** — redirection and supervisors may retain substantially more and potentially sensitive model output; route stderr to a controlled sink when needed. +- **Only reasoning and the final answer are printed** — a run without an assistant message prints an empty stdout line and exits 1; intermediate tool output is not printed. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index a05019b8d0..8d9084550f 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -1,20 +1,136 @@ -# `@deepseek-ai/dsh-headless` +--- +description: "dsh 的一次性任务模式:从命令行运行单个任务并打印最终答案,供用户脚本化或自动化 dsh。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-headless [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +## 概述 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.appExit` 宿主钩子([`dsh-cmdline`](../../boot/cmdline/README.md))请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终结束原因为 `error` 时,还会将 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 +`dsh-headless` 从命令行运行一个 dsh 任务并打印最终答案,然后退出——没有 GUI、没有服务器、没有浏览器。输入 `dsh --profile headless "run the tests"`,agent(智能体)会以与其他表层相同的模型、工具与安全默认值完成该任务。它非常适合脚本、CI 与一次性任务:进程不打开任何端口,也不会留下任何后台运行的东西。退出码告诉你结果——任务完成时为 0,中止或出错时为 1。主要边界:每次调用只运行一个任务,没有交互式后续。 +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +运行一个任务,获得最终答案,然后退出。任务就是命令行本身,因此整条命令就是最小的可运行示例。 + +### 运行一次性任务 + +```sh +dsh --profile headless "run the tests" +``` + +agent(智能体)会完成该任务,把提供方的每个非空推理增量流式写入 stderr 的 `dsh: reasoning:` 段,然后把最终答案写入 stdout 并退出。连续推理增量保持在同一段中;提供方未给尾换行时,runner 会在后续输出前结束该段。没有推理内容的成功运行保持 stderr 为空;失败时退出码为 1,并以 `dsh: : ` 向 stderr 写入错误。缺失或空白任务会在任何内容运行前被拒绝。任务文本通过唯一的 `task` 设置提供: + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `task` | 必填 | 单次运行的任务文本 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)是每个受支持字段及其 JSDoc 的穷尽式真源。 + +### 何时使用 + +在脚本化或自动化的 dsh 运行中使用 headless——CI 步骤、批处理任务、从终端快速获取答案。当需要多轮交互会话或 GUI 时请避免它;浏览器表层([dsh-web-app](../web-app/README.zh.md))负责这类场景。进程只为本次运行而存活,不打开监听端口,并且自行退出,因此适合等待进程结束的流水线。 + +### 帮助与任务错误 + +`dsh --profile headless --help` 打印该命令的帮助文本并直接退出,不运行任何内容。缺失或只有空白的任务属于用法错误:什么都不运行,进程退出 1。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +runner 是核心 API 载体之上的直接驱动器:它通过注册表创建一个全新的 Agent(智能体),并把所属的持久化事件区间折叠成一个进程级结果。 + +### 运行流程 + +runner 等待整个应用结算(`ctx.get('loader')?.await()`),确保已组合的工具与适配器不会半挂载,读取共享的 [`agentDefaultModel`](../../core/agent-default-model/README.zh.md) 选择,用该 provider 与模型创建一个全新的持久化 Agent(智能体),并把任务作为普通用户消息提交。它把该 Agent 的非空推理增量流式写入 stderr、等待完全停稳,然后 flush Session,并把所属区间(从 `firstSeq` 起)折叠为最后一条非空 `assistant/message` 文本与最终 `turn/end` 原因。最后,它把最终文本写入 stdout 并请求退出。 + +### 叠加在 base 之上的 patch 表层 + +patch 叠加在 `dsh-base` 之上:继承投影缓存,在基础 `system-prompt` 行上设置编码 persona,保留与 Web 表层相同的临时进程级 Code Mode 开关(`DSH_TOOLS_MODE`),禁用共享的 HMR 行,把 Code Mode 的 worker 作为核心执行能力插入,并挂载启动提供方与 runner。缓存为每个已持久化的一次性会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。启动提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.zh.md)),读取位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。 + +### 退出映射 + +最终 `turn/end` 完成时退出码为 0;任何其他结果——aborted、error,或所属区间内没有轮次——退出码为 1。结束原因为 `error` 时还会向 stderr 写入 `dsh: : `。直接驱动器失败(例如 Agent 创建失败)向 stderr 写入 `dsh: ` 并退出 1。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `headless-runner` 插件:运行流程、输出约定、退出映射 | +| [`src/startup.ts`](src/startup.ts) | `headless-startup` 提供方:任务位置参数与 `--help` | +| [`cordis.patch.yml`](cordis.patch.yml) | 叠加在 `dsh-base` 之上的一次性 patch | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:无运行时不变式;可观察约定是进程级的 | +| [`tests/headless.spec.ts`](tests/headless.spec.ts) | 运行流程、汇总、flush 与退出映射 | +| [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | + +### 不变式归属 + +不变式伴生插件注册一个空安装器,因为 runner 的可观察约定(stdout 的最终文本、按轮次结束原因决定的退出码)是进程级的、由启动器 e2e 负责;插件不注册任何内容,树内也没有任何可变关系可审计。 + +
+ +----- + + +## 进一步探索 + +当你想深入了解共享核心、兄弟 GUI 或命令行交接时,阅读以下页面。 + +- [组合包包映射](../README.zh.md)——基于同一核心构建的表层。 +- [dsh-base](../base/README.zh.md)——headless 运行其上的共享核心。 +- [dsh-web-app](../web-app/README.zh.md)——用于多轮工作的交互式浏览器兄弟表层。 +- [dsh-cmdline](../../boot/cmdline/README.zh.md)——启动器如何把命令行交给应用。 +- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-headless)——每个受支持配置字段及其源声明。 + +----- + + ## 模型体验 -无影响,因为 runner 把任务作为普通用户消息提交;提示词与工具由 base 和 headless 组合包中的相应条目提供。 +无,因为 runner 把任务作为普通用户消息提交,提示词与工具由组合出的 base 与 headless 行提供。 #### KV Cache 影响 -无;runner 不向请求前缀添加任何内容。 +runner 不向请求前缀添加任何内容;它只是把一条用户消息驱动经过组合出的配置树。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **只提交一个任务**:runner 没有用于交互式后续输入的 surface;它会等待 Agent 在返回 idle 前完成的所有工作,并打印该区间内最后一条非空 assistant 消息。 -- **`ctx.appExit` 由启动器持有**:在 `dsh` 启动器之外启动 headless profile 会在激活时明确报错,直到宿主提供该退出请求。 + + + +这些限制告诉你 headless 何时不适用、它需要 `dsh` 启动器提供什么。它们是当前包约束,不是通用的 CLI 对比或任务积压。 + +- **每次运行一个任务**——任务得到回答后进程即退出;没有交互式后续,因此多步工作请拆成多次运行。 +- **通过 `dsh` 启动器运行**——以其他方式启动 headless profile 会在启动时失败,因为只有启动器能请求进程退出。 +- **首个 token 前没有心跳**——提供方发出第一个非空推理增量前,stderr 保持静默;延迟首个 token 的提供方不会更早给出进度信号。 +- **推理进入 stderr 日志**——重定向与监督进程可能保留更多且可能敏感的模型输出;需要时应把 stderr 路由到受控位置。 +- **只打印推理和最终答案**——没有 assistant 消息的运行向 stdout 打印空行并以 1 退出;中间工具输出不会打印。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 972201ed9f..80cc07be60 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -9,11 +9,6 @@ persona: >- You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. -# The shared module-reload HMR row stays off; the launcher's watch-only -# fallback still keeps the user patch layers live until the run exits. -- id: hmr - disabled: true - - id: tools config: # Keep the same temporary process-wide Code Mode opt-in as the Web surface. diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 133c79bb22..d8c97032c1 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 6a0cbfbbed..b5b2839b00 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -2,7 +2,8 @@ * @deepseek-ai/dsh-headless — one-shot direct Agent driver. The bundle patch * rides over dsh-base without Host, HTTP, or browser plugins; this runner * creates one Agent through the core registry, drives the task to quiescence, - * flushes its Session, prints the final assistant text, and exits. + * streams provider reasoning to stderr, flushes its Session, prints the final + * assistant text to stdout, and exits. * * @module @deepseek-ai/dsh-headless */ @@ -11,9 +12,9 @@ import { randomUUID } from 'node:crypto' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { installModelSelection } from '@deepseek-ai/dsh-agent' -import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent' +import type { Agent, ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { assertNever, createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' // Empty type imports carry the loader Context merge for the settlement await @@ -81,6 +82,71 @@ function summarize(events: readonly SessionEvent[], firstSeq: number): RunOutcom return { text, reason } } +/** + * Project provider-reported reasoning from one owned run to stderr as it is + * appended, while keeping final outcome derivation on the durable log. + * @param ctx - plugin context carrying the Session event feed. + * @param agent - the exact Agent whose reasoning belongs to this invocation. + * @param stderr - progress output sink. + * @returns a disposer that also terminates an unterminated reasoning line. + */ +function streamReasoning( + ctx: Context, + agent: Agent, + stderr: HeadlessIo['stderr'], +): () => void { + let started = false + let open = false + let endsWithNewline = true + const close = (): void => { + if (!open) return + if (!endsWithNewline) stderr.write('\n') + open = false + endsWithNewline = true + } + const dispose = ctx.on('session/event', (session, event) => { + if (session !== agent.session) return + if (event.type === 'turn/start') { + close() + started = true + return + } + if (!started || event.type !== 'assistant/chunk') return + const chunk = event.data.chunk + switch (chunk.type) { + case 'reasoning-delta': + if (chunk.text === '') return + if (!open) { + stderr.write('dsh: reasoning:\n') + open = true + } + stderr.write(chunk.text) + endsWithNewline = chunk.text.endsWith('\n') + return + case 'block-start': + if (chunk.blockType !== 'reasoning') close() + return + case 'block-end': + if (chunk.block.type !== 'reasoning') close() + return + case 'usage': + return + case 'text-delta': + case 'tool-call-delta': + case 'finish': + close() + return + /* v8 ignore next -- closed-union exhaustiveness guard */ + default: + return assertNever(chunk, 'headless reasoning stream') + } + }) + return () => { + dispose() + close() + } +} + /** Report an unexpected direct-driver failure and request a failing exit. */ function fail(io: HeadlessIo, error: unknown): void { io.stderr.write(`dsh: ${error instanceof Error ? error.message : String(error)}\n`) @@ -119,11 +185,16 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { }) await agent.whenIdle() const firstSeq = agent.session.seq - agent.followup(createUserMessage({ - content: [{ type: 'text', text: task }], - source: { kind: 'user' }, - })) - await agent.whenIdle() + const stopReasoning = streamReasoning(ctx, agent, io.stderr) + try { + agent.followup(createUserMessage({ + content: [{ type: 'text', text: task }], + source: { kind: 'user' }, + })) + await agent.whenIdle() + } finally { + stopReasoning() + } await sessions.flush(agent.session) const outcome = summarize(agent.session.events, firstSeq) io.stdout.write(outcome.text + '\n') diff --git a/packages/bundle/headless/src/invariant.ts b/packages/bundle/headless/src/invariant.ts index cd435b5fcc..0d22891eb2 100644 --- a/packages/bundle/headless/src/invariant.ts +++ b/packages/bundle/headless/src/invariant.ts @@ -14,10 +14,10 @@ export const name = 'headless-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the runner is a one-shot driver over the API carrier - * whose observable contract (final text on stdout, exit code by turn-end - * reason) is process-level and owned by the launcher e2e; it registers - * nothing and holds no mutable relation to audit inside the tree. + * No runtime invariant: the runner's observable contract (provider reasoning + * on stderr, final text on stdout, exit code by turn-end reason) is + * process-level and owned by the launcher e2e; it registers nothing and holds + * no mutable relation to audit inside the tree. */ const install: InvariantInstaller = () => {} diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts index cb56b5ae9a..f1bc01125d 100644 --- a/packages/bundle/headless/src/startup.ts +++ b/packages/bundle/headless/src/startup.ts @@ -31,7 +31,7 @@ export interface HeadlessStartupValues { function headlessCommand(): Command { return new Command() .name('dsh --profile headless') - .description('Answer one task, print the final assistant message, and exit.') + .description('Answer one task, stream reasoning to stderr, print the final assistant message, and exit.') .helpOption('-h, --help', 'show this help') .argument('[task...]', 'the task text; multiple words are joined by spaces') .addHelpText('after', ` diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index ffe564870b..fb86ff8387 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -50,9 +50,13 @@ function appendTurn( /** Mount the real registries around a small scripted Agent factory. */ async function bench(script: Script): Promise<{ ctx: Context + output(): { out: string; err: string; order: string[] } run(): Promise<{ code: number; out: string; err: string; order: string[] }> }> { const ctx = new Context() + let out = '' + let err = '' + const order: string[] = [] await ctx.plugin(SessionStore) await ctx.plugin(AgentRegistry) await ctx.plugin(AgentDefaultModelConfig, { provider: 'test-provider', model: 'test-model' }) @@ -91,10 +95,8 @@ async function bench(script: Script): Promise<{ }) return { ctx, + output: () => ({ out, err, order: [...order] }), run: async () => { - let out = '' - let err = '' - const order: string[] = [] ctx.on('session/flush', () => { order.push('flush') }) internals.stdout = { write: (chunk: string) => { out += chunk; return true } } internals.stderr = { write: (chunk: string) => { err += chunk; return true } } @@ -143,6 +145,110 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('streams reasoning before the Agent becomes idle and terminates its stderr line', async () => { + const reasoningAppended = Promise.withResolvers() + const release = Promise.withResolvers() + const test = await bench({ + async afterPrompt(session, message) { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', message, { surfaceOp: 'append' }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 0, blockType: 'reasoning' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: '' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'checking the workspace' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: ' safely\n' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-end', index: 0, block: { type: 'reasoning', text: 'checking the workspace safely\n' } }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: { inputTokens: 1, outputTokens: 2, reasoningTokens: 2 } }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 1, blockType: 'reasoning' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 1, text: 'second pass\n' }, + }) + reasoningAppended.resolve(undefined) + await release.promise + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-start', index: 2, blockType: 'text' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 2, text: 'done' }, + }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'block-end', index: 2, block: { type: 'text', text: 'done' } }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: createAssistantMessage({ + content: [{ type: 'text', text: 'done' }], + source: { provider: 'test-provider', model: 'test-model' }, + }), + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + }, + }) + const running = test.run() + await reasoningAppended.promise + const other = test.ctx.sessions.create() + other.append('turn/start', { turn: 1 }) + other.append('step/start', { turn: 1, step: 1 }) + other.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'other session' }, + }) + const streamed = test.output() + release.resolve(undefined) + const result = await running + expect(streamed).toEqual({ + out: '', + err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n', + order: [], + }) + expect(result).toEqual({ + code: 0, + out: 'done\n', + err: 'dsh: reasoning:\nchecking the workspace safely\nsecond pass\n', + order: ['flush', 'exit'], + }) + await test.ctx.fiber.dispose() + }) + it('exits 1 when the final turn does not complete', async () => { const test = await bench({ afterPrompt(session, message) { appendTurn(session, 1, message, undefined, false) }, @@ -172,6 +278,32 @@ describe('headless runner', () => { await test.ctx.fiber.dispose() }) + it('separates an unterminated reasoning prefix from the terminal model failure', async () => { + const test = await bench({ + afterPrompt(session, message) { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + session.append('user/message', message, { surfaceOp: 'append' }) + session.append('assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'reasoning-delta', index: 0, text: 'trying recovery' }, + }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { + turn: 1, + reason: { kind: 'error', error: { code: 'SERVER', message: 'provider unavailable' } }, + }) + }, + }) + expect(await test.run()).toMatchObject({ + code: 1, + out: '\n', + err: 'dsh: reasoning:\ntrying recovery\ndsh: SERVER: provider unavailable\n', + }) + await test.ctx.fiber.dispose() + }) + it('exits 1 when the owned interval contains no turn', async () => { const test = await bench({ afterPrompt: () => {} }) expect(await test.run()).toMatchObject({ code: 1, out: '\n', err: '' }) diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts index 07c200202e..3db8d68bf4 100644 --- a/packages/bundle/headless/tests/startup.spec.ts +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -99,6 +99,7 @@ describe('headless command-line provider', () => { it('prints its own help and leaves the runner pending', async () => { const { task, observed } = await bootStartup(['--help']) expect(observed.out).toContain('dsh --profile headless') + expect(observed.out).toContain('stream reasoning to stderr') expect(task).toBeUndefined() expect(observed.runnerConfig).toBeUndefined() expect(observed.exits).toEqual([0]) diff --git a/packages/bundle/sdk-app/README.i18n.yaml b/packages/bundle/sdk-app/README.i18n.yaml new file mode 100644 index 0000000000..83a58e47bb --- /dev/null +++ b/packages/bundle/sdk-app/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bundle/sdk-app/README.md +README.md: 36b1964d6a22a3fa1ddc384cdf5f973b51fc1ca4 +README.zh.md: b4871e937ec1f6a7e8b03ac09a0bd03bbb0f55ca diff --git a/packages/bundle/sdk-app/README.md b/packages/bundle/sdk-app/README.md new file mode 100644 index 0000000000..36b1964d6a --- /dev/null +++ b/packages/bundle/sdk-app/README.md @@ -0,0 +1,70 @@ +--- +description: "SDK stdio application profile for users and maintainers launching a JSON-RPC harness runtime." +kind: "package-bundle" +--- + +# `@deepseek-ai/dsh-sdk-app` + +English | [中文](README.zh.md) + +## Summary + +The SDK stdio application as a `dsh` profile bundle over [`dsh-base`](../base/README.md). It inherits the base's disabled module-HMR policy; its patch sets the coding-agent persona, mounts an app-owned zero-option command provider, and starts [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.md) only after that provider accepts the invocation. `dsh --profile sdk --help` therefore writes help and exits without claiming stdin or stdout. The standalone [`sdk-minimal`](../sdk-minimal/README.md) bundle reuses the same startup provider with its own profile name. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +The startup provider binds stdin EOF to the launcher's bounded successful shutdown. SDK protocol `shutdown`, SIGINT, and SIGTERM retain their owning server or launcher paths; disposal drains the root profile tree and persistence. Stdout is reserved for newline-delimited JSON-RPC frames. The bundle disables model-generated session titles because the SDK exposes no title surface; deterministic fallback titles remain durable without an auxiliary model request. The inherited projection cache checkpoints SDK-created sessions for later consumers; its durability barrier flushes each covered log prefix before publishing the cache row and may split otherwise coalesced JSONL runs. A deployment selects a different complete composition through profile bundles and patch files, not another app bin. + +| Config | Default | Behavior | +|---|---|---| +| `profile` | `sdk` | Profile name rendered in command help; a bundle mounting this provider sets its own shipped profile name. | + +`DSH_MAX_TOKENS_AS_SUCCESS` retains the SDK deployment mapping: unset or JSON `true` reports token-limited subagent completion as accepted, while JSON `false` reports it as an error. Provider/model and workspace cwd arrive through the SDK initialization request; the base profile owns adapters, tools, persistence, policy, settings, and credentials. + +----- + + +## Model Experience + +### SDK coding-agent persona + +#### What the model sees + +The profile supplies `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.` before the base tool and context contributions. The exact SDK initialization route and session cwd resolve the placeholders. + +#### Token effect + +One short stable persona plus the data-dependent base prompt sections and selected tool schemas. + +#### KV Cache effect + +Stable for a fixed profile, provider, model, and tool roster. Profile changes take effect on the next process because the shipped SDK profile uses startup-only patches. + +## Known Limitations and Deferred Work + + + +- **A profile can omit the SDK server** — a custom profile selected by the TypeScript client must retain this bundle or another `dsh-sdk-jsonrpc-server` row; client initialization fails when no peer answers. +- **User plugins can violate stdout purity** — profile and per-launch patches are trusted application composition. The shipped bundle writes no non-protocol stdout, but it cannot contain an arbitrary inserted plugin. +- **Configuration changes require restart** — the shipped `sdk` profile uses `patchReload: startup` so one stdio connection never observes a replacement server or Agent dependency. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/bundle/sdk-app/README.zh.md b/packages/bundle/sdk-app/README.zh.md new file mode 100644 index 0000000000..b4871e937e --- /dev/null +++ b/packages/bundle/sdk-app/README.zh.md @@ -0,0 +1,70 @@ +--- +description: "面向启动 JSON-RPC harness 运行时的用户与维护者,说明 SDK stdio 应用 profile。" +kind: "package-bundle" +--- + +# `@deepseek-ai/dsh-sdk-app` + +[English](README.md) | 中文 + +## 概述 + +以 [`dsh-base`](../base/README.zh.md) 为基础的 SDK stdio 应用 `dsh` profile 组合包。它继承 base 默认禁用模块 HMR(热模块替换)的策略;其 patch 设置 coding agent(编程智能体)persona、挂载应用自有的零选项命令提供方,并且只在该提供方接受调用后启动 [`dsh-sdk-jsonrpc-server`](../../sdk/server/README.zh.md)。因此,`dsh --profile sdk --help` 会写出 help 并退出,不会占用 stdin 或 stdout。独立的 [`sdk-minimal`](../sdk-minimal/README.zh.md) bundle 复用同一个启动提供方,并提供自己的 profile 名称。 + +## 目录 + +- [使用本包](#use-this-package) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +启动提供方把 stdin EOF 接到启动器的有界成功关闭流程。SDK 协议 `shutdown`、SIGINT 与 SIGTERM 继续使用各自所属的 server 或启动器路径;dispose(资源释放)会排空根 profile 配置树与持久化。stdout 专用于按换行分隔的 JSON-RPC 帧。SDK 不提供 title 表层,因此本组合包禁用模型生成的 session title;确定性的 fallback title 仍会持久化,但不发起辅助模型请求。继承的投影缓存会为 SDK 创建的会话写入检查点,供后续消费方使用;其持久性屏障会在发布缓存行前 flush 所覆盖的日志前缀,因此可能拆分原本会合并的 JSONL 行。部署通过 profile 组合包与 patch 文件选择另一套完整组合,而不是使用另一个应用 bin。 + +| 配置 | 默认值 | 行为 | +|---|---|---| +| `profile` | `sdk` | 命令帮助中显示的 profile 名称;挂载此提供方的 bundle 会设置自己的随附 profile 名称。 | + +`DSH_MAX_TOKENS_AS_SUCCESS` 保留 SDK 部署映射:未设置或 JSON `true` 把 token 达限的 subagent 完成报告为已接受,JSON `false` 则报告为错误。模型提供方/模型与工作区 cwd 通过 SDK 初始化请求传入;base profile 拥有适配器、工具、持久化、策略、settings 与 credentials。 + +----- + + +## 模型体验 + +### SDK coding agent persona + +#### 模型看到什么 + +profile 会在 base 工具与上下文贡献之前提供 `You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}.`。确切的 SDK 初始化路由与会话 cwd 会解析其中的占位符。 + +#### Token 影响 + +一段简短稳定的 persona,加上随数据变化的 base 提示词段落与所选工具 schema。 + +#### KV Cache 影响 + +对固定 profile、提供方、模型与工具清单保持稳定。由于随附 SDK profile 使用仅启动时 patch,profile 变化会在下一个进程生效。 + +## 已知限制与延期工作 + + + +- **profile 可以省略 SDK server**:TypeScript client 选择的自定义 profile 必须保留本组合包或另一个 `dsh-sdk-jsonrpc-server` 配置项;没有 peer 响应时,client 初始化会失败。 +- **用户插件可以破坏 stdout 纯净性**:profile 与逐次启动 patch 属于受信任应用组合。随附组合包不会向 stdout 写入非协议内容,但无法约束任意插入插件。 +- **配置变化需要重启**:随附 `sdk` profile 使用 `patchReload: startup`,因此一个 stdio 连接不会观察到 server 或 Agent 依赖被替换。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/bundle/sdk-app/cordis.patch.yml b/packages/bundle/sdk-app/cordis.patch.yml new file mode 100644 index 0000000000..373e7aeb63 --- /dev/null +++ b/packages/bundle/sdk-app/cordis.patch.yml @@ -0,0 +1,21 @@ +# The SDK application over dsh-base. Stdout belongs exclusively to JSON-RPC. + +- id: system-prompt + config: + persona: >- + You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + +- id: session-title-llm + disabled: true + +- insert: + - id: sdk-app-startup + name: '@deepseek-ai/dsh-sdk-app' + config: + profile: sdk + + - id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' + inject: [sdkAppStartup, loader] + config: + maxTokensAsSuccess: !!js "process.env.DSH_MAX_TOKENS_AS_SUCCESS === undefined ? true : JSON.parse(process.env.DSH_MAX_TOKENS_AS_SUCCESS)" diff --git a/packages/bundle/sdk-app/package.json b/packages/bundle/sdk-app/package.json new file mode 100644 index 0000000000..6ab4167d8d --- /dev/null +++ b/packages/bundle/sdk-app/package.json @@ -0,0 +1,56 @@ +{ + "name": "@deepseek-ai/dsh-sdk-app", + "description": "The dsh SDK profile bundle: stdio JSON-RPC serving and process lifecycle over dsh-base", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/sdk-app" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", + "commander": "^15.0.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/bundle/sdk-app/src/index.ts b/packages/bundle/sdk-app/src/index.ts new file mode 100644 index 0000000000..fec847af53 --- /dev/null +++ b/packages/bundle/sdk-app/src/index.ts @@ -0,0 +1,62 @@ +/** + * The SDK profile's command-line and stdin-lifetime provider. A successful + * parse publishes {@link SDK_APP_STARTUP_SERVICE}; the JSON-RPC server waits + * for that service, so help starts no transport. + * @module @deepseek-ai/dsh-sdk-app + */ + +import { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { exitOnStdinEnd, parseCmdline } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'sdk-app-startup' + +/** Launcher service required before this app can parse its invocation. */ +export const inject = ['cmdlineArgs'] + +/** Service the JSON-RPC server row waits for before claiming stdio. */ +export const SDK_APP_STARTUP_SERVICE = 'sdkAppStartup' + +/** SDK stdio startup configuration. */ +export interface Config { + /** Profile name rendered in help and diagnostics (default `sdk`). */ + profile?: string +} + +/** Validate and default SDK stdio startup configuration. */ +export const Config: z = z.object({ + profile: z.string().default('sdk'), +}) + +/** + * Build this app's zero-option command and help. + * @param profile - selected profile name rendered in the command grammar. + * @returns a fresh program for one invocation. + */ +function sdkCommand(profile: string): Command { + return new Command() + .name(`dsh --profile ${profile}`) + .description('Serve DeepSeek Harness SDK clients over stdio JSON-RPC.') + .helpOption('-h, --help', 'show this help') + .addHelpText('after', ` +Example: + dsh --profile ${profile} serve one SDK runtime until its client disconnects +`) +} + +/** + * Accept an SDK profile invocation, publish readiness, and bind EOF to the + * launcher's bounded shutdown. + * @param ctx - plugin context carrying command-line and exit launcher values. + * @param config - selected profile identity for command help. + */ +export function apply(ctx: Context, config: Config = {}): void { + const program = sdkCommand(config.profile ?? 'sdk') + program.action(() => { + exitOnStdinEnd(ctx, 'sdk-app.stdin') + ctx.provide(SDK_APP_STARTUP_SERVICE, { accepted: true }) + }) + parseCmdline(ctx, program) +} diff --git a/packages/bundle/sdk-app/src/invariant.ts b/packages/bundle/sdk-app/src/invariant.ts new file mode 100644 index 0000000000..c3e6c41d63 --- /dev/null +++ b/packages/bundle/sdk-app/src/invariant.ts @@ -0,0 +1,28 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-app`. + * @module @deepseek-ai/dsh-sdk-app/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-app' + +/** Cordis companion plugin name. */ +export const name = 'sdk-app-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the bundle adds a process transport and startup latch; + * source/built stdio tests own frame purity, help exclusion, and shutdown. + */ +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)) diff --git a/packages/bundle/sdk-app/tests/sdk-app.spec.ts b/packages/bundle/sdk-app/tests/sdk-app.spec.ts new file mode 100644 index 0000000000..a716868deb --- /dev/null +++ b/packages/bundle/sdk-app/tests/sdk-app.spec.ts @@ -0,0 +1,29 @@ +/** The SDK app bundle's declared profile patch. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('dsh-sdk-app bundle', () => { + it('declares startup-gated JSON-RPC serving without overriding base HMR policy', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + expect(manifest.dependencies).toHaveProperty('@deepseek-ai/dsh-sdk-jsonrpc-server') + const patches = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) as Array<{ id?: string; disabled?: boolean; insert?: Array<{ id?: string; inject?: string[]; name?: string }> }> + expect(patches.find(patch => patch.id === 'hmr')).toBeUndefined() + expect(patches.find(patch => patch.id === 'session-title-llm')).toMatchObject({ disabled: true }) + const rows = patches.flatMap(patch => patch.insert ?? []) + expect(rows.find(row => row.id === 'sdk-app-startup')?.name).toBe('@deepseek-ai/dsh-sdk-app') + expect(rows.find(row => row.id === 'sdk-jsonrpc-server')?.inject).toEqual(['sdkAppStartup', 'loader']) + }) +}) diff --git a/packages/bundle/sdk-app/tests/startup.spec.ts b/packages/bundle/sdk-app/tests/startup.spec.ts new file mode 100644 index 0000000000..ec128a1c4a --- /dev/null +++ b/packages/bundle/sdk-app/tests/startup.spec.ts @@ -0,0 +1,71 @@ +/** The SDK app command provider and stdin shutdown binding. */ + +import { EventEmitter } from 'node:events' +import { Context } from '@deepseek-ai/cordis' +import { afterEach, describe, expect, it } from 'vitest' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { apply, type Config, SDK_APP_STARTUP_SERVICE } from '../src/index.ts' + +/** Controllable stdin for one startup invocation. */ +class TestStdin extends EventEmitter { + readableEnded = false + + resume(): this { + return this + } + + end(): void { + this.readableEnded = true + this.emit('end') + } +} + +afterEach(() => { + internals.stdin = process.stdin + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** Run the provider with captured command output and exit requests. */ +function start(args: string[], config: Config = {}): { ctx: Context; exits: number[]; out: () => string; stdin: TestStdin } { + const ctx = new Context() + const exits: number[] = [] + const stdin = new TestStdin() + let out = '' + const capture = { write: (chunk: string) => { out += chunk; return true } } + internals.stdin = stdin + internals.stdout = capture + internals.stderr = capture + provideCmdline(ctx, { + args, + exit: code => void exits.push(code), + ready: { onReady: (listener) => { listener(); return () => {} } }, + }) + apply(ctx, config) + return { ctx, exits, out: () => out, stdin } +} + +describe('SDK app startup', () => { + it('publishes readiness and requests bounded exit on client EOF', async () => { + const { ctx, exits, stdin } = start([]) + expect(ctx.get(SDK_APP_STARTUP_SERVICE)).toEqual({ accepted: true }) + stdin.end() + expect(exits).toEqual([0]) + await ctx.fiber.dispose() + }) + + it('prints app help without publishing readiness or binding stdin', () => { + const { ctx, exits, out, stdin } = start(['--help']) + expect(out()).toContain('dsh --profile sdk') + expect(ctx.get(SDK_APP_STARTUP_SERVICE)).toBeUndefined() + expect(exits).toEqual([0]) + stdin.end() + expect(exits).toEqual([0]) + }) + + it('renders the selected SDK profile name in help', () => { + const { out } = start(['--help'], { profile: 'sdk-minimal' }) + expect(out()).toContain('Usage: dsh --profile sdk-minimal') + expect(out()).toContain('dsh --profile sdk-minimal') + }) +}) diff --git a/packages/bundle/sdk-app/tsconfig.json b/packages/bundle/sdk-app/tsconfig.json new file mode 100644 index 0000000000..0a98d3117c --- /dev/null +++ b/packages/bundle/sdk-app/tsconfig.json @@ -0,0 +1,24 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../runtime-diagnostics/invariants" + }, + { + "path": "../../boot/cmdline" + } + ] +} diff --git a/packages/bundle/sdk-minimal/README.i18n.yaml b/packages/bundle/sdk-minimal/README.i18n.yaml new file mode 100644 index 0000000000..8230113dff --- /dev/null +++ b/packages/bundle/sdk-minimal/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write packages/bundle/sdk-minimal/README.md +README.md: cf9333cd4304f24344b79d54b4149dadf246c325 +README.zh.md: eeda31bff9e34fd20dae0e43c5678c0e398e0649 diff --git a/packages/bundle/sdk-minimal/README.md b/packages/bundle/sdk-minimal/README.md new file mode 100644 index 0000000000..cf9333cd43 --- /dev/null +++ b/packages/bundle/sdk-minimal/README.md @@ -0,0 +1,105 @@ +--- +description: "Standalone two-tool SDK profile for users who need a minimal cross-platform coding agent without the shared base bundle." +kind: "package-bundle" +--- + +# `@deepseek-ai/dsh-sdk-minimal` + +English | [中文](README.zh.md) + +## Summary + +Use `dsh --profile sdk-minimal` when an SDK client needs a small, explicit coding-agent runtime. The profile advertises a platform-selected persistent shell and `str_replace_editor`, persists sessions as uncompressed JSONL, and selects the model from the SDK initialization request. It supplies a complete Cordis tree and deliberately excludes `dsh-base`, Web, settings, managed credentials, telemetry, compaction, workspace instructions, skills, jobs, and subagents. Its danger-full-access policy lets the shell and editor modify any path available to the process, so use it only with an isolated workspace. + +## Table of Contents + +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Launch the profile directly or select it from the Python SDK. Supply an explicit `DSH_HOME`, use a disposable workspace, and provide the model credential through `DEEPSEEK_API_KEY`. + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh --profile sdk-minimal +``` + +`DSH_CONTEXT_WINDOW` sets the fallback capacity for a model absent from the adapter's advisory catalog. `DSH_SYSTEM_PROMPT` replaces the default persona. The SDK initialization request is the sole model selection and overrides environment defaults. + +Use `dsh plugin --profile sdk-minimal` to manage persistent external dependencies. Profile, home, and ordered `--patch` files can replace rows or insert bundles above the complete default tree. The shipped template applies patches only at startup. + +The profile mounts exactly one persistent shell stack: Bash on Linux and macOS, or PowerShell on Windows. Both stacks use a 300-second timeout and one owner-scoped terminal; the other platform's rows remain disabled. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The bundle's single insert is the complete application tree: SDK stdio startup and JSON-RPC serving, one environment-configured DeepSeek adapter, the executor-less agent spine, local subprocess and unrestricted filesystem providers, a platform-selected persistent shell PTY, the string-replace editor, and uncompressed JSONL persistence under `$DSH_HOME/sessions`. It does not inherit another bundle, so every extra row is an explicit profile change. + +### Source map + +| File | Role | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | Complete standalone profile tree and its environment-backed defaults | +| [`src/index.ts`](src/index.ts) | Bundle package entry | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion for the static composition | +| [`tests/sdk-minimal.spec.ts`](tests/sdk-minimal.spec.ts) | Exact composition, profile-name, and platform-selection checks | + +
+ +----- + + +## Further Exploration + +- [Python SDK example](../../../python/sdk/examples/README.md) — launches this profile from Python against an explicit Harness home. +- [SDK application bundle](../sdk-app/README.md) — the JSON-RPC application layer reused by full and minimal SDK profiles. +- [Base bundle](../base/README.md) — the full product foundation that this profile deliberately omits. + +----- + + +## Model Experience + +### Minimal coding-agent composition + +#### What the model sees + +The system prompt is `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.`. The only advertised tools are owner-scoped persistent `bash` on Linux/macOS or `pwsh` on Windows, plus `str_replace_editor`; runtime context, workspace instructions, skills, jobs controls, compaction, and Harness identity are absent. + +#### Token effect + +One stable persona plus the two tool schemas. Tool results and ordinary conversation history grow with the session. + +#### KV Cache effect + +Stable for a fixed persona, platform, provider, model, and bundle patch stack. Profile changes take effect on the next process. + +## Known Limitations and Deferred Work + + + +- **The composition intentionally omits shared product services** — select `dsh --profile sdk` when settings, managed credentials, policy presets, telemetry, Web tools, or the full default tool roster are required. +- **User patches can expand the tree and corrupt stdout** — profile customization is trusted application composition; a plugin that writes ordinary text to stdout can break JSON-RPC framing. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/bundle/sdk-minimal/README.zh.md b/packages/bundle/sdk-minimal/README.zh.md new file mode 100644 index 0000000000..eeda31bff9 --- /dev/null +++ b/packages/bundle/sdk-minimal/README.zh.md @@ -0,0 +1,105 @@ +--- +description: "供需要不含共享 base bundle 的极简跨平台 coding agent 的用户使用的独立双工具 SDK profile。" +kind: "package-bundle" +--- + +# `@deepseek-ai/dsh-sdk-minimal` + +[English](README.md) | 中文 + +## 概述 + +当 SDK 客户端需要小型、显式的 coding agent 运行时时,请使用 `dsh --profile sdk-minimal`。该 profile 只公布按平台选择的持久 shell 与 `str_replace_editor`,把会话持久化为未压缩 JSONL,并从 SDK 初始化请求选择模型。它提供完整 Cordis 配置树,并刻意排除 `dsh-base`、Web、settings、托管凭据、遥测、compaction、workspace 指令、skills、jobs 与 subagent。其 danger-full-access 策略允许 shell 与编辑器修改进程可访问的任何路径,因此只能配合隔离 workspace 使用。 + +## 目录 + +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +直接启动该 profile,或从 Python SDK 选择它。提供显式 `DSH_HOME`、使用一次性 workspace,并通过 `DEEPSEEK_API_KEY` 提供模型凭据。 + +```sh +export DSH_HOME=/absolute/path/to/example-dsh-home +dsh --profile sdk-minimal +``` + +`DSH_CONTEXT_WINDOW` 为不在适配器建议目录中的模型设置后备容量。`DSH_SYSTEM_PROMPT` 替换默认 persona。SDK 初始化请求是唯一模型选择,并覆盖环境默认值。 + +使用 `dsh plugin --profile sdk-minimal` 管理持久外部依赖。Profile、home 与有序 `--patch` 文件可以在完整默认配置树上替换配置项或插入 bundle。随附模板只在启动时应用 patch。 + +该 profile 只挂载一套持久 shell:Linux 和 macOS 使用 Bash,Windows 使用 PowerShell。两套配置都使用 300 秒超时与一个 agent 自有终端;另一平台的配置项保持禁用。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +该 bundle 的单个 insert 就是完整应用配置树:SDK stdio 启动与 JSON-RPC 服务、一个由环境配置的 DeepSeek 适配器、无执行器 agent 主干、本地子进程与不受限文件系统提供方、按平台选择的持久 shell PTY、字符串替换编辑器,以及位于 `$DSH_HOME/sessions` 的未压缩 JSONL 持久化。它不继承其他 bundle,因此每个额外配置项都是显式 profile 变更。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`cordis.patch.yml`](cordis.patch.yml) | 完整独立 profile 配置树及其环境默认值 | +| [`src/index.ts`](src/index.ts) | Bundle 包入口 | +| [`src/invariant.ts`](src/invariant.ts) | 静态组合的不变式伴生插件 | +| [`tests/sdk-minimal.spec.ts`](tests/sdk-minimal.spec.ts) | 精确组合、profile 名称与平台选择检查 | + +
+ +----- + + +## 进一步探索 + +- [Python SDK 示例](../../../python/sdk/examples/README.zh.md)——从 Python 针对显式 Harness home 启动本 profile。 +- [SDK 应用 bundle](../sdk-app/README.zh.md)——完整与极简 SDK profile 复用的 JSON-RPC 应用层。 +- [Base bundle](../base/README.zh.md)——本 profile 刻意省略的完整产品基础。 + +----- + + +## 模型体验 + +### 极简 coding agent 组合 + +#### 模型看到的内容 + +系统提示词取 `DSH_SYSTEM_PROMPT`,未设置时使用 `You are a helpful software engineer assistant.`。对外公布的工具只有 Linux/macOS 上 agent 所有的持久 `bash` 或 Windows 上的 `pwsh`,外加 `str_replace_editor`;运行时上下文、workspace 指令、skills、jobs 控制、compaction 与 Harness 身份均不存在。 + +#### Token 影响 + +一个稳定 persona 加两个工具 schema。工具结果与普通对话历史随会话增长。 + +#### KV Cache 影响 + +当 persona、平台、提供方、模型与 bundle patch 栈固定时保持稳定。Profile 变更在下一个进程生效。 + +## 已知限制与延期工作 + + + +- **该组合刻意省略共享产品服务** — 需要 settings、托管凭据、权限策略预设、遥测、Web 工具或完整默认工具清单时,请选择 `dsh --profile sdk`。 +- **用户 patch 可以扩展配置树并破坏 stdout** — profile 自定义属于受信任的应用组合;向 stdout 写入普通文本的插件会破坏 JSON-RPC 分帧。 + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/bundle/sdk-minimal/cordis.patch.yml b/packages/bundle/sdk-minimal/cordis.patch.yml new file mode 100644 index 0000000000..361e9a6f53 --- /dev/null +++ b/packages/bundle/sdk-minimal/cordis.patch.yml @@ -0,0 +1,118 @@ +# Standalone minimal SDK application. Unlike the ordinary SDK profile, this +# bundle does not layer over dsh-base: this insert is the complete Cordis tree. +# User profile, home, and invocation patches still apply above it. + +- insert: + - id: sdk-app-startup + name: '@deepseek-ai/dsh-sdk-app' + config: + profile: sdk-minimal + + - id: sdk-jsonrpc-server + name: '@deepseek-ai/dsh-sdk-jsonrpc-server' + inject: [sdkAppStartup, loader] + config: + maxTokensAsSuccess: false + + - id: deepseek-llm-api-extensions + name: '@deepseek-ai/dsh-deepseek-llm-api-extensions' + + - id: session-log-deepseek + name: '@deepseek-ai/dsh-session-log-deepseek' + + - id: plugin-package-inventory-deepseek + name: '@deepseek-ai/dsh-plugin-package-inventory-deepseek' + + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKeyEnv: DEEPSEEK_API_KEY + defaultContextWindow: !!js Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000) + streamIdleTimeoutMs: 172800000 + + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + + - id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.cwd() + + - id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + + - id: pty + name: '@deepseek-ai/dsh-terminal' + + - id: terminal-bash + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform === 'win32' + config: + timeoutMs: 300000 + + - id: terminal-pwsh + name: '@deepseek-ai/dsh-terminal-bash' + disabled: !!js process.platform !== 'win32' + config: + shellDialect: pwsh + timeoutMs: 300000 + + # The editor uses the bare local filesystem; persistent Bash still consumes + # the shared danger-full-access sandbox policy above. + - id: fs-local + name: '@deepseek-ai/dsh-fs-local' + config: + cwd: !!js process.cwd() + + - id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + includeRuntimeContext: false + persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' + workspaceContext: false + skills: + enabled: false + toolBash: false + toolJobs: false + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + disabled: !!js process.platform === 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + + - id: persistent-pwsh + name: '@deepseek-ai/dsh-tool-pwsh-persistent' + disabled: !!js process.platform !== 'win32' + config: + timeoutMs: 300000 + description: |- + Run commands in a PowerShell shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * State is persistent across command calls and discussions with the user. + * Use native Windows paths (C:\...) and $env:NAME variables; this is PowerShell, not bash. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'Start-Job' or start a server with Start-Process. + + - id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + + - id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js dshHomePath('sessions') + compression: none diff --git a/packages/bundle/sdk-minimal/package.json b/packages/bundle/sdk-minimal/package.json new file mode 100644 index 0000000000..42940a0ac8 --- /dev/null +++ b/packages/bundle/sdk-minimal/package.json @@ -0,0 +1,68 @@ +{ + "name": "@deepseek-ai/dsh-sdk-minimal", + "description": "The standalone minimal SDK profile bundle: JSON-RPC, one DeepSeek adapter, persistent shell, editor, and JSONL sessions", + "version": "0.1.1-rc.2", + "publishConfig": { + "access": "public" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/sdk-minimal" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./cordis.patch.yml": "./cordis.patch.yml", + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "cordis.patch.yml", + "lib/types/**/*.d.ts" + ], + "license": "MIT", + "dsh": { + "bundle": { + "patch": "./cordis.patch.yml" + } + }, + "dependencies": { + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-deepseek-llm-api-extensions": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-plugin-package-inventory-deepseek": "workspace:^", + "@deepseek-ai/dsh-sandbox-local": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-sdk-app": "workspace:^", + "@deepseek-ai/dsh-sdk-jsonrpc-server": "workspace:^", + "@deepseek-ai/dsh-session-log-deepseek": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-subprocess-local": "workspace:^", + "@deepseek-ai/dsh-terminal": "workspace:^", + "@deepseek-ai/dsh-terminal-bash": "workspace:^", + "@deepseek-ai/dsh-tool-bash-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-pwsh-persistent": "workspace:^", + "@deepseek-ai/dsh-tool-str-replace-editor": "workspace:^" + }, + "peerDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/bundle/sdk-minimal/src/index.ts b/packages/bundle/sdk-minimal/src/index.ts new file mode 100644 index 0000000000..a5f162161e --- /dev/null +++ b/packages/bundle/sdk-minimal/src/index.ts @@ -0,0 +1,9 @@ +/** + * @deepseek-ai/dsh-sdk-minimal — the standalone minimal SDK profile bundle. + * The package's substance is `cordis.patch.yml`, declared by the + * `dsh.bundle.patch` manifest field and resolved by the profile composer; + * this module carries no runtime interface. + * @module @deepseek-ai/dsh-sdk-minimal + */ + +export {} diff --git a/packages/bundle/sdk-minimal/src/invariant.ts b/packages/bundle/sdk-minimal/src/invariant.ts new file mode 100644 index 0000000000..e2f480504a --- /dev/null +++ b/packages/bundle/sdk-minimal/src/invariant.ts @@ -0,0 +1,26 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-sdk-minimal`. + * @module @deepseek-ai/dsh-sdk-minimal/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-minimal' + +/** Cordis companion plugin name. */ +export const name = 'sdk-minimal-bundle-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +// No runtime invariant: the package is a static patch-list carrier whose +// inserted rows own their runtime relationships and invariant companions. +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)) diff --git a/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts new file mode 100644 index 0000000000..7983a7793c --- /dev/null +++ b/packages/bundle/sdk-minimal/tests/sdk-minimal.spec.ts @@ -0,0 +1,73 @@ +/** The standalone SDK-minimal bundle's complete declared Cordis tree. */ + +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import * as yaml from 'js-yaml' +import { describe, expect, it } from 'vitest' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' + +describe('dsh-sdk-minimal bundle', () => { + it('declares one standalone allowlisted tree with every row dependency', () => { + const root = fileURLToPath(new URL('..', import.meta.url)) + const manifest = JSON.parse(readFileSync(resolve(root, 'package.json'), 'utf8')) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } + expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') + const patches = yaml.load( + readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), + { schema: entryListSchema }, + ) as Array<{ insert?: Array<{ id?: string; inject?: string[]; name?: string; config?: Record; disabled?: unknown }> }> + expect(patches).toHaveLength(1) + const rows = patches[0]?.insert ?? [] + expect(rows.map(row => [row.id, row.name])).toEqual([ + ['sdk-app-startup', '@deepseek-ai/dsh-sdk-app'], + ['sdk-jsonrpc-server', '@deepseek-ai/dsh-sdk-jsonrpc-server'], + ['deepseek-llm-api-extensions', '@deepseek-ai/dsh-deepseek-llm-api-extensions'], + ['session-log-deepseek', '@deepseek-ai/dsh-session-log-deepseek'], + ['plugin-package-inventory-deepseek', '@deepseek-ai/dsh-plugin-package-inventory-deepseek'], + ['llm-deepseek', '@deepseek-ai/dsh-llm-deepseek'], + ['sandbox', '@deepseek-ai/dsh-sandbox-local'], + ['sandbox-policy', '@deepseek-ai/dsh-sandbox-policy'], + ['subprocess', '@deepseek-ai/dsh-subprocess-local'], + ['pty', '@deepseek-ai/dsh-terminal'], + ['terminal-bash', '@deepseek-ai/dsh-terminal-bash'], + ['terminal-pwsh', '@deepseek-ai/dsh-terminal-bash'], + ['fs-local', '@deepseek-ai/dsh-fs-local'], + ['agent-spine', '@deepseek-ai/dsh-agent-spine-demo'], + ['persistent-bash', '@deepseek-ai/dsh-tool-bash-persistent'], + ['persistent-pwsh', '@deepseek-ai/dsh-tool-pwsh-persistent'], + ['str-replace-editor', '@deepseek-ai/dsh-tool-str-replace-editor'], + ['sessions', '@deepseek-ai/dsh-session-persistence-jsonl'], + ]) + expect(rows.find(row => row.id === 'sdk-app-startup')?.config).toEqual({ profile: 'sdk-minimal' }) + expect(rows.find(row => row.id === 'sdk-jsonrpc-server')).toMatchObject({ + inject: ['sdkAppStartup', 'loader'], + config: { maxTokensAsSuccess: false }, + }) + expect(rows.find(row => row.id === 'llm-deepseek')?.config).toEqual({ + apiKeyEnv: 'DEEPSEEK_API_KEY', + defaultContextWindow: { __jsExpr: 'Number(process.env.DSH_CONTEXT_WINDOW ?? 1000000)' }, + streamIdleTimeoutMs: 172800000, + }) + expect(rows.find(row => row.id === 'agent-spine')?.config).toMatchObject({ + includeHarnessIdentity: false, + includeRuntimeContext: false, + workspaceContext: false, + skills: { enabled: false }, + toolBash: false, + toolJobs: false, + }) + expect(rows.find(row => row.id === 'terminal-bash')).toMatchObject({ + disabled: { __jsExpr: "process.platform === 'win32'" }, + }) + expect(rows.find(row => row.id === 'terminal-pwsh')).toMatchObject({ + disabled: { __jsExpr: "process.platform !== 'win32'" }, + config: { shellDialect: 'pwsh', timeoutMs: 300000 }, + }) + expect(Object.keys(manifest.dependencies ?? {}).sort()).toEqual( + [...new Set(rows.map(row => row.name).filter((name): name is string => name !== undefined))].sort(), + ) + }) +}) diff --git a/packages/bundle/sdk-minimal/tsconfig.json b/packages/bundle/sdk-minimal/tsconfig.json new file mode 100644 index 0000000000..8f58ed6e28 --- /dev/null +++ b/packages/bundle/sdk-minimal/tsconfig.json @@ -0,0 +1,18 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../runtime-diagnostics/invariants" + } + ] +} diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 2a1c5b01db..237686c71a 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/bundle/web-app/README.md -README.md: 28fb5b3dcfc7fbb912493a6b97495e2ed5a3eece -README.zh.md: 92157f05497e53c48506666a3a53d638d98a7c9e +README.md: ed2c341c3eb5b3a5ad2b298c4babd0fc19a066e2 +README.zh.md: 1f57a0beff91fc341bef6f9c6e79dab4f1d1b564 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index 28fb5b3dcf..ed2c341c3e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -1,20 +1,130 @@ -# `@deepseek-ai/dsh-web-app` +--- +description: "The browser GUI for dsh: interactive chat, model and settings management, and session history, for users running the dsh web surface." +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-web-app English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +## Summary -## Model retry defaults +Run `dsh --profile web` and the interface opens in your default browser, ready for interactive chat with the agent. You get the conversation view, model and settings management, and session history, backed by the same model access, tools, and safety defaults as every other surface. The command prints a tokenized startup URL; the browser exchanges that token for a signed session cookie and redirects to the clean root URL. You can change the port, suppress the browser handoff, and allow extra hosts from the command line; binding all network interfaces is intentionally not supported. Choose it for interactive work in the browser; `dsh-headless` is the one-shot command-line sibling. -Web uses the shared bounded normal default of five eligible retries after the initial request. The `deepseek-official` route and settings-added pi-ai routes use that default when they omit `retryPolicy`; explicit provider policies still win. Web adds no retry-specific composition override, so the same omission behavior applies to non-Web profiles. +## Table of Contents +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + +## Use this package + +Start the GUI, open your browser, and start talking to the agent. The flags fine-tune the invocation. + +### Starting the Web GUI + +```sh +dsh --profile web +dsh --profile web --no-open --port 8080 +``` + +After startup you see a `dsh web:` line whose root URL carries a fresh process token. Unless `--no-open` or an SSH session suppresses it, the default browser opens that URL, receives a signed cookie, and redirects to the clean root page. You know it worked when the page loads and you can chat with the agent. Two failures to expect: if the frontend is not built, startup stops with a build hint (`pnpm run build` in a checkout); if the browser cannot be opened, a credential-free diagnostic prints to stderr while the server keeps running — open the printed startup URL yourself. + +### Configuration + +Most users never set these; the command-line flags feed the four settings below — `--host`, `--port`, and `--trusted-host` come from the invocation, and `--no-open` turns the browser handoff off for that invocation: + +| Field | Default | Meaning | +|---|---|---| +| `openBrowser` | `true` | Open the default browser after startup; SSH launches suppress it | +| `printUrl` | `true` | Print the `dsh web:` URL line at startup | +| `surfaceContext` | `true` | Give the agent GUI-orientation context and expose `DSH_WEB_URL` to its shell commands | +| `trustedHosts` | `[]` | Extra hosts allowed to reach the GUI from the network | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) is the exhaustive source for every accepted field and its JSDoc. + +### LAN access and trusted hosts + +By default the GUI accepts connections from this machine only. A deployment that binds all network interfaces also allows browsers from the LAN, and the printed URL then includes a LAN address; `--trusted-host` adds extra hosts in either case. Host and Origin checks control reachability, while the token exchange authenticates every Host API method and WebSocket stream. The LAN addresses are sampled once at startup, so a network change later is not picked up — restart the GUI to re-advertise. + +### Running over SSH + +When you launch `dsh --profile web` over SSH, the URL line still prints but the browser is not opened for you: the SSH client or editor owns the local forwarding address. Open the forwarded URL on your machine yourself; the printed URL names the remote host's loopback endpoint. + +### Per-session agent setup + +Each browser session composes its own agent from the shipped presets (the `standard` preset by default), instead of sharing one process-wide tool set. You can change the default preset or add your own presets under `$DSH_HOME/.agent-presets`. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The bundle is one patch plus one runtime glue plugin. The storage stack and projection cache come from `dsh-base`; the web overlay's workspace and message-feedback rows consume that shared `storageDomain` service. The patch restates the surface-specific values the base deliberately omits, inserts the web-only host rows and browser roster, then moves the agent plane behind presets. The glue plugin owns dist serving, trust sampling, prompt sections, the bash variable, and the readiness announcements. + +### Patch semantics + +A patch replaces the targeted row's whole `config`, so each web row restates every key it owns: the persona, the `DSH_TOOLS_MODE` Code Mode opt-in, and the `session-query-sqlite` values on the base rows, then `insert` adds the web host rows, transport, and browser roster. The per-agent tool rows the base mounts process-wide are disabled here and the preset roster takes over; the reasoning for each host-plane versus preset-plane decision is inline in the patch. + +### Readiness + +The URL line and browser handoff are readiness signals: supervisors RPC as soon as they observe the line, and a browser requests the page as soon as it opens, so both run only after the Loader tree settles and Connection authentication is available — or immediately in a hand-built tree without a Loader. A tree disposed mid-boot announces nothing. + +### LAN trust sampling + +`resolveLanTrust` samples the network once at boot: a loopback bind (`127.0.0.1`) derives no LAN addresses, while an all-interfaces bind adds every non-internal IPv4 literal. The derived literals plus the explicit `--trusted-host` authorities form the `/api` browser-trust fence, and the printed LAN URL always matches that fence. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | The `web-app` glue plugin: dist resolution, LAN trust sampling, prompt sections, bash variable, URL line, browser handoff | +| [`src/startup.ts`](src/startup.ts) | The `web-startup` provider: `--host`, `--port`, `--trusted-host`, `--no-open`, `--help` | +| [`cordis.patch.yml`](cordis.patch.yml) | The web patch: restated base values, web host rows, browser roster, agent plane behind presets | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: no runtime invariant; every contribution is registry-disposed | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | Dist resolution, fallback seat, prompt sections, readiness | +| [`tests/startup.spec.ts`](tests/startup.spec.ts) | Command-line parsing over a real Loader tree | +| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN-trust sampling | +| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | Default-browser handoff after the page is reachable | + +### Invariant ownership + +The invariant companion registers an empty installer because every contribution — the frontend-static child plugin, the prompt sections, and the bash variable registration — is registry-disposed with the fiber, and each owning registry's package carries that relation's invariant. + +
+ +----- + + +## Further Exploration + +Read these pages when you want to go deeper into the shared core, the browser reload pipeline, or the built frontend. + +- [Bundle package map](../README.md) — the surfaces built on the same core. +- [dsh-base](../base/README.md) — the shared core the GUI runs on. +- [dsh-client-hmr](../../client/hmr/README.md) — how client-plugin changes reload during development. +- [frontend-static](../../host/frontend-static/README.md) — how the built frontend is served. +- [Generated configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-web-app) — every accepted config field and its source declaration. + +----- + + ## Model Experience ### Harness-source and Web-surface context #### What the model sees -When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (first-party order −800) orients the model to the GUI: the canonical local URL, the "this page" referent, the update contract (the reload receiver is always on; no-refresh reloads additionally need the `pnpm run dev:web` watcher), and the instruction not to start replacement servers. `DSH_WEB_URL` additionally appears in the managed bash environment with its description, resolved per invocation from the live server. When it is false, neither section nor the variable is registered. #### Token effect @@ -26,5 +136,24 @@ The prompt section sits near the system prompt's head and is stable for the life ## Known Limitations and Deferred Work -- **The frontend dist must be built** — `require.resolve` of the dist fails loud at activation with a build hint; there is no source-serving fallback. -- **`lanAddresses` is a boot-time snapshot** — interface changes after boot are not re-advertised; the printed LAN URL always matches the configured trust fence. + + + +These limits tell you what to expect in unusual setups — a source checkout, SSH sessions, or strict networks. They are current package constraints, not a general browser comparison or a task backlog. + +- **The frontend must be built** — a source checkout needs `pnpm run build` first; startup stops with a build hint when the dist is missing, and there is no source-serving fallback. +- **LAN addresses are sampled once at startup** — interface changes after boot are not re-advertised; the printed LAN URL always matches what was sampled. +- **Only the handoff start is observable** — the GUI reports that the browser was asked to open, not that it actually opened; a later browser exit is never reported, and the printed URL is your manual fallback. +- **SSH sessions keep the URL but skip the browser handoff** — the printed URL names the remote host's loopback endpoint; the SSH client or editor must expose and open the local forwarded address. +- **`BROWSER` overrides only come from the environment** — a discovered `.env` cannot set `BROWSER`; only an inherited value can choose the executable for the automatic handoff. +- **Binding all network interfaces is not supported** — `--host 0.0.0.0` is rejected at startup for safety; use the default loopback host. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 92157f0549..1f57a0beff 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -1,20 +1,130 @@ -# `@deepseek-ai/dsh-web-app` +--- +description: "dsh 的浏览器 GUI:交互式聊天、模型与设置管理、会话历史,供用户运行 dsh web 表层。" +kind: "package-bundle" +--- + +# @deepseek-ai/dsh-web-app [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +## 概述 -## 模型重试默认值 +运行 `dsh --profile web`,界面会在你的默认浏览器中打开,即可与 agent(智能体)交互式聊天。你会获得会话视图、模型与设置管理以及会话历史,背后与其他表层相同的模型访问、工具与安全默认值。该命令会打印带 token 的启动 URL;浏览器用该 token 换取签名会话 cookie,再重定向到干净的根 URL。你可以从命令行更改端口、关闭浏览器交接并允许额外主机;有意不支持绑定所有网络接口。需要浏览器中的交互式工作时选择它;`dsh-headless` 是一次性的命令行兄弟表层。 -Web 使用共享的有界 normal 默认值,在首次请求后最多再重试五次符合条件的失败。`deepseek-official` 与由 settings 新增的 pi-ai 路由在省略 `retryPolicy` 时使用该默认值;显式提供方策略仍然优先。Web 不再增加重试专用的组合覆盖,因此非 Web profile 的省略行为与之相同。 +## 目录 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + +## 使用本包 + +启动 GUI、打开浏览器,然后开始与 agent(智能体)对话。flag 用于微调本次调用。 + +### 启动 Web GUI + +```sh +dsh --profile web +dsh --profile web --no-open --port 8080 +``` + +启动后你会看到 `dsh web:` 行,其根 URL 携带新的进程 token。除非 `--no-open` 或 SSH 会话抑制,否则默认浏览器会打开该 URL、取得签名 cookie,再重定向到干净的根页面。页面加载且你可以与 agent(智能体)对话,就说明成功了。两种可预期的失败:前端未构建时,启动会以构建提示停止(checkout 中运行 `pnpm run build`);浏览器无法打开时,stderr 会打印不含凭据的诊断,但服务器会继续运行——请自行打开已打印的启动 URL。 + +### 配置 + +大多数用户不需要设置这些;命令行 flag 会提供给下面四个设置——`--host`、`--port` 与 `--trusted-host` 来自本次调用,`--no-open` 仅对本次调用关闭浏览器交接: + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `openBrowser` | `true` | 启动后用默认浏览器打开;SSH 启动会抑制它 | +| `printUrl` | `true` | 启动时打印 `dsh web:` URL 行 | +| `surfaceContext` | `true` | 给 agent(智能体)提供 GUI 定位上下文,并把 `DSH_WEB_URL` 暴露给其 shell 命令 | +| `trustedHosts` | `[]` | 允许从网络访问 GUI 的额外主机 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)是每个受支持字段及其 JSDoc 的穷尽式真源。 + +### LAN 访问与可信主机 + +默认情况下 GUI 只接受本机的连接。绑定所有网络接口的部署也会允许 LAN 内的浏览器访问,此时打印的 URL 会附带一个 LAN 地址;`--trusted-host` 在两种情况下都能添加额外主机。Host 与 Origin 检查控制可达性,token 交换则认证每个 Host API 方法与 WebSocket stream。LAN 地址只在启动时采样一次,因此之后的网络变化不会被感知——重启 GUI 以重新公告。 + +### 通过 SSH 运行 + +通过 SSH 启动 `dsh --profile web` 时,URL 行仍会打印,但不会为你打开浏览器:本地转发地址由 SSH 客户端或编辑器持有。请在自己的机器上打开转发后的 URL;打印出的 URL 指向远端宿主机 loopback 端点。 + +### 按会话的 agent 设置 + +每个浏览器会话都从随发行版交付的 preset(默认 `standard`)组合自己的 agent(智能体),而不是共享一套进程级工具集。你可以更改默认 preset,或在 `$DSH_HOME/.agent-presets` 下添加自己的 preset。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本组合包是一份 patch 加一个运行时粘合插件。patch 重述 base 刻意省略的表层专属值,插入仅 Web 使用的宿主行与浏览器名录,然后把 agent 层移到 preset 之后;粘合插件负责 dist 服务、信任采样、提示词段落、bash 变量与就绪宣告。 + +### patch 语义 + +patch 会替换目标行的整个 `config`,因此每个 Web 行都重述自己拥有的每个键:基础行上的 persona、`DSH_TOOLS_MODE` Code Mode 开关与 `session-query-sqlite` 值,随后 `insert` 添加 Web 宿主行、传输层与浏览器名录。base 以进程级挂载的按 agent 工具行在这里被禁用,由 preset 名录接管;每项宿主层与 preset 层归属决策的理由以行内注释写在 patch 里。 + +### 就绪宣告 + +URL 行与浏览器交接都是就绪信号:监督方一观察到该行就发起 RPC,浏览器一打开就请求页面,因此两者只在 Loader 配置树结算且 Connection 认证可用后运行——在没有 Loader 的手工构建树中则立即运行。启动中途被释放的树不会宣告任何内容。 + +### LAN 信任采样 + +`resolveLanTrust` 在启动时只采样一次网络:loopback 绑定(`127.0.0.1`)不派生任何 LAN 地址,绑定所有网卡则会加入每个非 internal IPv4 字面量。派生字面量加上显式的 `--trusted-host` 权威标识组成 `/api` 浏览器信任栅栏,打印的 LAN URL 始终与该栅栏一致。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `web-app` 粘合插件:dist 解析、LAN 信任采样、提示词段落、bash 变量、URL 行、浏览器交接 | +| [`src/startup.ts`](src/startup.ts) | `web-startup` 提供方:`--host`、`--port`、`--trusted-host`、`--no-open`、`--help` | +| [`cordis.patch.yml`](cordis.patch.yml) | Web patch:重述的基础值、Web 宿主行、浏览器名录、preset 之后的 agent 层 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:无运行时不变式;每项贡献都由 registry 释放 | +| [`tests/web-app.spec.ts`](tests/web-app.spec.ts) | dist 解析、fallback 席位、提示词段落、就绪宣告 | +| [`tests/startup.spec.ts`](tests/startup.spec.ts) | 在真实 Loader 树上的命令行解析 | +| [`tests/trusted-hosts.spec.ts`](tests/trusted-hosts.spec.ts) | LAN 信任采样 | +| [`tests/browser-open.spec.ts`](tests/browser-open.spec.ts) | 页面可达后的默认浏览器交接 | + +### 不变式归属 + +不变式伴生插件注册一个空安装器,因为每项贡献——frontend-static 子插件、提示词段落与 bash 变量注册——都会随 fiber 由 registry 释放,且每个所属 registry 的包负责该关系的不变式。 + +
+ +----- + + +## 进一步探索 + +当你想深入了解共享核心、浏览器重载流水线或已构建的前端时,阅读以下页面。 + +- [组合包包映射](../README.zh.md)——基于同一核心构建的表层。 +- [dsh-base](../base/README.zh.md)——GUI 运行其上的共享核心。 +- [dsh-client-hmr](../../client/hmr/README.zh.md)——开发期间客户端插件变更如何重载。 +- [frontend-static](../../host/frontend-static/README.zh.md)——已构建的前端如何被服务。 +- [生成配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-web-app)——每个受支持配置字段及其源声明。 + +----- + + ## 模型体验 ### Harness 源码与 Web 表层上下文 -#### 模型看到的内容 +#### 模型看到什么 -当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(first-party 顺序 −800)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、更新约定(重载接收端始终开启;无刷新重载还需要 `pnpm run dev:web` watcher),以及不要启动替代服务器的指令。`DSH_WEB_URL` 还会连同描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和该变量都不会注册。 #### Token 影响 @@ -26,5 +136,24 @@ Web 使用共享的有界 normal 默认值,在首次请求后最多再重试 ## 已知限制与延期工作 -- **前端 dist 必须已构建**:对 dist 的 `require.resolve` 在激活时明确报错并给出构建提示;没有从源码直接服务的回退路径。 -- **`lanAddresses` 是启动期快照**:启动后的网卡变化不会重新公告;打印的 LAN URL 始终与配置的信任栅栏一致。 + + + +这些限制告诉你在不常见的环境下会遇到什么——源码 checkout、SSH 会话或严格网络。它们是当前包约束,不是通用的浏览器对比或任务积压。 + +- **前端必须已构建**——源码 checkout 需要先运行 `pnpm run build`;dist 缺失时启动会以构建提示停止,且没有从源码直接服务的回退路径。 +- **LAN 地址只在启动时采样一次**——启动后的网卡变化不会重新公告;打印的 LAN URL 始终与采样结果一致。 +- **只能观察到交接的启动**——GUI 只报告浏览器被请求打开,而不是它确实打开了;之后的浏览器退出永远不会上报,打印的 URL 是你的手动回退路径。 +- **SSH 会话保留 URL 但跳过浏览器交接**——打印的 URL 指向远端宿主机 loopback 端点;SSH 客户端或编辑器必须暴露并打开本地转发地址。 +- **`BROWSER` 覆盖只能来自环境**——被发现的 `.env` 不能设置 `BROWSER`;只有继承值能为自动交接选择可执行文件。 +- **不支持绑定所有网络接口**——出于安全考虑,`--host 0.0.0.0` 会在启动时被拒绝;请使用默认 loopback 主机。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 203f8ff017..9e4f71923f 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -18,10 +18,6 @@ persona: >- You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. -# TODO: Re-enable shared HMR for Web after its reload lifecycle is tested. -- id: hmr - disabled: true - # Full-text session search is opt-in (the base row's `openAt: never`). This # restatement keeps the Web values on one ephemeral in-memory index; a # deployment enabling content search overrides `openAt` to `first-search` in a @@ -45,22 +41,14 @@ # `dsh.client` rows are the browser roster the modules node half scans into # window.__DSH_BOOT__; the modules row is simultaneously a host row. - insert: + # Host-owned opt-in sampled when a new Web session receives its preset + # delegation tools. The Models page edits this settings namespace. + - id: subagent-model-selection-settings + name: '@deepseek-ai/dsh-tool-subagent/model-selection-settings' + - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker-thread' - - id: storage - name: '@deepseek-ai/dsh-storage' - - - id: storage-json - name: '@deepseek-ai/dsh-storage-json' - config: - root: !!js dshHomePath('storages') - - - id: storage-domain - name: '@deepseek-ai/dsh-storage-domain' - config: - backend: json - - id: message-feedback name: '@deepseek-ai/dsh-message-feedback' config: @@ -73,11 +61,11 @@ - id: workspace name: '@deepseek-ai/dsh-workspace' - - id: session-projection-cache - name: '@deepseek-ai/dsh-session-projection-cache' - config: - writeEveryEvents: 200 - writeIntervalMs: 5000 + - id: session-reference + name: '@deepseek-ai/dsh-session-reference' + + - id: file-reference-local + name: '@deepseek-ai/dsh-file-reference-local' # Whole-log turn/step counts for the chat stats strip (the sessionStats # projection key); the projection registry itself is a base-layer row. @@ -94,6 +82,14 @@ - id: plugin-inventory name: '@deepseek-ai/dsh-host-plugin-inventory' + # Session commands, cold reads, and live control over Typert Remote. + - id: session-controller + name: '@deepseek-ai/dsh-api-session-controller' + + # Workspace commands and reconnect-safe projection over Typert Remote. + - id: workspace-controller + name: '@deepseek-ai/dsh-api-workspace-controller' + # The API gateway: the transport-agnostic dispatch face every client shape # shares. The base layer's agent-default-model service owns the default model. - id: api-gateway @@ -118,19 +114,24 @@ config: host: !!js ctx.webStartup.host ?? '127.0.0.1' port: !!js ctx.webStartup.port ?? 3080 + compression: gzip + compressionLevel: 1 + compressionThresholdBytes: 1024 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and the bash runtime variable, and prints the URL line. The - # webStartup provider supplies invocation-only values; after the server - # binds, this row samples LAN trust once and provides `webRuntime`. A - # complete agent-preset persona suppresses the prompt section for that - # agent while retaining the host-owned shell variable. + # section and the bash runtime variable, prints the URL line, and opens the + # canonical local URL after the full tree settles. The webStartup provider + # supplies invocation-only values; after the server binds, this row samples + # LAN trust once and provides `webRuntime`. A complete agent-preset persona + # suppresses the prompt section for that agent while retaining the host-owned + # shell variable. - id: web-runtime name: '@deepseek-ai/dsh-web-app' inject: [webStartup] config: + openBrowser: !!js ctx.webStartup.openBrowser printUrl: true surfaceContext: true trustedHosts: !!js ctx.webStartup.trustedHosts @@ -165,9 +166,6 @@ - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' - - id: client-runtime - name: '@deepseek-ai/dsh-client-runtime' - - id: cordis-client-runner name: '@deepseek-ai/dsh-cordis-client-runner' @@ -183,6 +181,9 @@ - id: ui-renderer name: '@deepseek-ai/dsh-client-ui-renderer' + - id: ui-session + name: '@deepseek-ai/dsh-client-ui-session' + - id: ui-sidebar name: '@deepseek-ai/dsh-client-ui-sidebar' @@ -201,6 +202,16 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + - id: ui-approval + name: '@deepseek-ai/dsh-client-ui-approval' + + - id: ui-chat + name: '@deepseek-ai/dsh-client-ui-chat' + + # Official occupants for the generic sidebar and conversation brand slots. + - id: ui-brand-official + name: '@deepseek-ai/dsh-client-ui-brand-official' + - id: ui-attachment name: '@deepseek-ai/dsh-client-ui-attachment' @@ -226,7 +237,7 @@ name: '@deepseek-ai/dsh-client-ui-workspace' # Input triggers: the '/' | '@' pipeline (ui-input-trigger), the command surface over - # it (ui-commands), and the two reference sources (ui-skill / ui-subagent). + # it (ui-commands), and the reference sources (ui-skill / ui-reference). - id: ui-input-trigger name: '@deepseek-ai/dsh-client-ui-input-trigger' @@ -239,6 +250,9 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' + - id: ui-reference + name: '@deepseek-ai/dsh-client-ui-reference' + # Background jobs: the session-header list over the jobsBySession mirror. - id: ui-jobs name: '@deepseek-ai/dsh-client-ui-jobs' @@ -339,14 +353,11 @@ - id: tool-skill disabled: true -# The goal SERVICE, its session driver, and the `/goal` command STAY on the -# host plane; only the model-facing tool moves. The Gateway serves the goal -# domain as Remote endpoints, and a Remote method picks its receiver Service -# from a generated descriptor — it resolves `goals` on the host, so a -# per-session realm would answer `service-unavailable` for every browser call. -# That is the `shell-env` criterion read from the other side: injection is not -# the only host relationship a Service can have. The registry is keyed by -# session, so one host instance serves every session exactly as before presets. +# The goal service and session driver stay on the host plane, where Gateway +# remotes resolve them. Presets own the human command and model-facing tool. + +- id: command-goal + disabled: true - id: tool-goal disabled: true @@ -413,16 +424,13 @@ - id: tool-web disabled: true -# The preset roster. `config/agent-presets/` ships with the deployment and is -# read-only (its entries carry `system` trust); `$DSH_HOME/.agent-presets` is -# where a person — or an agent — authors their own, and carries the same trust -# as shell access because a preset IS a composition. -# -# Only the SHIPPED root is an assembly fact: it sits beside the installed app's -# own config, so `apps/cli`'s `composeProfile` resolves and patches it in — the -# same treatment `distIndex` gets on the webserver row. The writable root is -# `dsh-agent-presets`' own default (`includeUserRoot`), so a composition that -# never reaches that patch still finds a person's presets. +# The preset roster. The shipped presets are bundled inside +# `dsh-agent-presets` itself and prepended as a read-only `system` root +# (`includeShippedRoot`); `$DSH_HOME/.agent-presets` is where a person — or an +# agent — authors their own, appended by the same package (`includeUserRoot`), +# and carries the same trust as shell access because a preset IS a +# composition. This row only names the default and any deployment-added +# `roots`; no launcher patching is involved. - insert: - id: agent-presets name: '@deepseek-ai/dsh-agent-presets' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 3cf68746c0..dfd0b68de3 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -45,6 +45,7 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", @@ -52,10 +53,12 @@ "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-renderer": "workspace:^", - "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-ui-attachment": "workspace:^", + "@deepseek-ai/dsh-client-ui-approval": "workspace:^", + "@deepseek-ai/dsh-client-ui-brand-official": "workspace:^", "@deepseek-ai/dsh-client-ui-commands": "workspace:^", + "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-cordis": "workspace:^", "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", @@ -69,6 +72,7 @@ "@deepseek-ai/dsh-client-ui-settings-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-client-ui-permission-presets": "workspace:^", "@deepseek-ai/dsh-client-ui-plan": "workspace:^", + "@deepseek-ai/dsh-client-ui-session": "workspace:^", "@deepseek-ai/dsh-client-ui-settings-plugins": "workspace:^", "@deepseek-ai/dsh-client-ui-user-questions": "workspace:^", "@deepseek-ai/dsh-client-ui-settings": "workspace:^", @@ -76,6 +80,7 @@ "@deepseek-ai/dsh-client-ui-sidebar": "workspace:^", "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-input-trigger": "workspace:^", + "@deepseek-ai/dsh-client-ui-reference": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", "@deepseek-ai/dsh-client-ui-jobs": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", @@ -95,16 +100,20 @@ "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-plugin-inventory": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-file-reference": "workspace:^", + "@deepseek-ai/dsh-file-reference-local": "workspace:^", + "@deepseek-ai/dsh-launch-environment": "workspace:^", "@deepseek-ai/dsh-message-feedback": "workspace:^", - "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-session-log-export": "workspace:^", "@deepseek-ai/dsh-session-stats": "workspace:^", - "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-api-session-controller": "workspace:^", + "@deepseek-ai/dsh-api-workspace-controller": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", "@deepseek-ai/schemastery": "workspace:^", - "commander": "^15.0.0" + "commander": "^15.0.0", + "open": "^11.0.0" }, "peerDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index 9b5207dd02..d0b2635f8e 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -5,21 +5,27 @@ * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the * harness-source and web-surface prompt sections, the bash-visible web runtime - * variable, and the URL line. App command-line values arrive through the - * `webStartup` service expressions in the bundle patch. + * variable, the process-token URL line, and the default-browser handoff. The + * model and shell retain the clean URL. App command-line values arrive through + * the `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ +import { spawn, type ChildProcess } from 'node:child_process' import { createRequire } from 'node:module' +import { dirname, join } from 'node:path' import { networkInterfaces } from 'node:os' import { fileURLToPath } from 'node:url' import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import type {} from '@deepseek-ai/dsh-client-connection' import * as FrontendStatic from '@deepseek-ai/dsh-host-frontend-static' +import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment' +import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' -import type {} from '@deepseek-ai/dsh-system-prompt' +import { FIRST_PARTY_SECTION_ORDER } from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-shell-env' /** Stable Cordis plugin name. */ @@ -27,6 +33,7 @@ export const name = 'web-app' /** This dsh installation's root, from either this package's source or built entry. */ const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) +const ANNOUNCED_ROOTS = new WeakSet() /** Runtime service that releases Web rows after bind-dependent values resolve. */ const WEB_RUNTIME_SERVICE = 'webRuntime' @@ -36,6 +43,8 @@ export const inject = ['webServer'] /** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { + /** Permit default-browser handoff after the Loader tree settles; an SSH launch suppresses it. */ + openBrowser: boolean /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** @@ -50,6 +59,7 @@ export interface Config { } export const Config: z = z.object({ + openBrowser: z.boolean().default(true), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), trustedHosts: z.array(String).default([]), @@ -72,6 +82,46 @@ const LOOPBACK_HOST = '127.0.0.1' /** The webserver schema's all-interfaces bind literal. */ const ALL_INTERFACES_HOST = '0.0.0.0' +/** Whether this process was launched through SSH, including a forwarded-port session. */ +function launchedThroughSsh(ctx: Context): boolean { + const environment = launchEnvironmentOf(ctx) + return ['SSH_CONNECTION', 'SSH_TTY'].some((name) => { + const value = environment.getFrom(name, ['process'])?.value + return value !== undefined && value !== '' + }) +} + +const BROWSER_OPENER_MODULE = import.meta.resolve('open') + +const BROWSER_OPENER_PROGRAM = ` +try { + const { default: open } = await import(${JSON.stringify(BROWSER_OPENER_MODULE)}) + const launcher = await open(process.argv[1]) + if (process.platform === 'win32') { + // open resolves at PowerShell spawn; keep it referenced until that launcher hands the URL to Windows. + const code = launcher.exitCode ?? await new Promise((resolve, reject) => { + function onError(error) { + launcher.off('close', onClose) + reject(error) + } + function onClose(code) { + launcher.off('error', onError) + resolve(code) + } + launcher.ref() + launcher.once('error', onError) + launcher.once('close', onClose) + }) + if (code !== 0) throw new Error('browser operating-system launcher exited with code ' + String(code)) + } + process.exitCode = 0 +} catch (error) { + // The parent turns this exit into the manual-URL warning. + console.error(error) + process.exitCode = 1 +} +` + /** * Resolve one LAN-trust snapshot from the active server bind. * @@ -112,28 +162,81 @@ function localWebUrl(ctx: Context): string { return `http://${LOOPBACK_HOST}:${String(port)}` } -/** Dist location is workspace knowledge of this bundle: resolved through the frontend package exports, not configured. */ +/** + * Dist location is workspace knowledge of this bundle: anchored on the + * frontend package manifest, not configured. Existence is a request-time + * concern — the fallback owner reads files per request, so a composition + * whose page never reaches the fallback seat (the static worker preview + * ships its own page and carries no dist) boots without one. + */ function resolveDistIndex(): string { const require = createRequire(import.meta.url) try { - return require.resolve('@deepseek-ai/dsh-web-frontend/dist/index.html') + return join(dirname(require.resolve('@deepseek-ai/dsh-web-frontend/package.json')), 'dist', 'index.html') } catch { - /* v8 ignore next 2 -- reachable only on a checkout without a built dist; the test tree builds it */ - throw new Error('web-app: frontend dist not built; run pnpm run build from the repository root first') + /* v8 ignore next 2 -- reachable only when the frontend package is absent from the checkout */ + throw new Error('web-app: @deepseek-ai/dsh-web-frontend is not resolvable from this composition') } } -/** Test hook: hosts with no built frontend dist substitute the resolver; production never touches this. */ -export const internals: { resolveDistIndex: () => string } = { resolveDistIndex } +/** Start the maintained platform opener without forwarding Harness credentials. */ +function spawnBrowserLauncher(url: string): ChildProcess { + return spawn(process.execPath, [ + '--input-type=module', + '--eval', BROWSER_OPENER_PROGRAM, + '--', url, + ], { + env: scrubbedParentEnv(), + stdio: ['ignore', 'inherit', 'pipe'], + }) +} + +/** Hand one URL to the operating system's default browser. */ +async function openBrowser(url: string): Promise { + const launcher = spawnBrowserLauncher(url) + let launcherStderr = '' + launcher.stderr?.setEncoding('utf8') + launcher.stderr?.on('data', (chunk: string) => { launcherStderr += chunk }) + await new Promise((resolve, reject) => { + function onError(error: Error): void { + launcher.off('close', onClose) + reject(error) + } + function onClose(code: number | null): void { + launcher.off('error', onError) + if (code !== 0) { + const firstLine = launcherStderr.trim().split(/\r?\n/u)[0] + const reason = firstLine === undefined || firstLine === '' + ? `browser launcher exited with code ${String(code)}` + : firstLine.replace(/^(?:[A-Za-z]*Error):\s*/u, '') + reject(new Error(reason)) + return + } + if (launcherStderr !== '') process.stderr.write(launcherStderr) + resolve() + } + launcher.once('error', onError) + launcher.once('close', onClose) + }) +} + +/** Test hooks for the built dist and native browser handoff; production never mutates them. */ +export const internals: { + resolveDistIndex: () => string + openBrowser: (url: string) => Promise +} = { resolveDistIndex, openBrowser } /** * Mount the Web runtime: dist serving, surface prompt, the bash runtime - * variable, and the URL line. + * variable, the URL line, and the default-browser handoff. * @param ctx - plugin context carrying the webServer service. * @param config - validated {@link Config}. */ export function apply(ctx: Context, config: Config): void { const runtime = resolveLanTrust(ctx.webServer.host, config.trustedHosts) + // The loopback URL belongs to this host. Under SSH, the operator reaches it + // through a local forwarding address that this process cannot derive. + const handoffBrowser = config.openBrowser && !launchedThroughSsh(ctx) // Release dependent rows only after bind-dependent trust has been sampled once. ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) @@ -142,7 +245,7 @@ export function apply(ctx: Context, config: Config): void { addHarnessSourceSection(promptCtx, SOURCE_ROOT) promptCtx.systemPrompt.section({ name: 'app:web-surface', - order: -98, + order: FIRST_PARTY_SECTION_ORDER.WEB_SURFACE, text: () => webSurfacePrompt(localWebUrl(promptCtx)), }) }) @@ -156,30 +259,51 @@ export function apply(ctx: Context, config: Config): void { }) }) } - if (config.printUrl) { - // The URL line is a readiness signal: supervisors (and the keyless CLI - // smoke) RPC as soon as they observe it, so it must not print while - // sibling rows (the /api route owner) are still mounting. Await Loader - // settlement first; a hand-built tree without a Loader prints at once. - const printUrl = (): void => { - // Reuse the exact LAN snapshot provided to the /api trust fence. - const lanCandidate = runtime.lanAddresses[0] - const port = ctx.webServer.port - console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) - } - // This row's own activation can precede a sibling failure. The app owns - // readiness by waiting for its Loader tree, or prints at once in a - // hand-built context without Loader. - const settled = ctx.get('loader')?.await() - if (settled === undefined) printUrl() - else { - void settled.then(() => { - // The tree can be disposed while the boot was in flight (early - // SIGTERM); a URL line for a dead server would only mislead, and - // reading the torn-down port would turn a clean shutdown into a crash. - if (ctx.get('webServer') !== undefined) printUrl() - // Loader reports a failed boot; this row only stays quiet. - }, () => {}) - } + if (config.printUrl || handoffBrowser) { + ctx.inject(['connection'], (connectionCtx) => { + // The URL line and browser handoff are readiness signals: supervisors RPC + // as soon as they observe the line, while a browser requests the page as + // soon as it opens. Neither may run while sibling rows such as the /api + // route owner are still mounting. Await Loader settlement first; a + // hand-built tree without a Loader is already the complete tree. + const announceReady = (): void => { + if (ANNOUNCED_ROOTS.has(connectionCtx.root)) return + const webUrl = localWebUrl(connectionCtx) + const authenticatedUrl = connectionCtx.connection.authenticatedUrl(webUrl) + // Reuse the exact LAN snapshot provided to the /api trust fence. + const lanCandidate = runtime.lanAddresses[0] + const port = connectionCtx.webServer.port + const lanUrl = lanCandidate === undefined + ? undefined + : connectionCtx.connection.authenticatedUrl(`http://${lanCandidate}:${String(port)}`) + ANNOUNCED_ROOTS.add(connectionCtx.root) + if (config.printUrl) { + console.log(`dsh web: ${authenticatedUrl}${lanUrl === undefined ? '' : ` (LAN: ${lanUrl})`}`) + } + if (handoffBrowser) { + console.log('dsh web: opening the default browser; pass --no-open to disable') + void internals.openBrowser(authenticatedUrl).catch((error: unknown) => { + const reason = error instanceof Error ? error.message : String(error) + console.error(`web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`) + }) + } + } + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or announces at once in a + // hand-built tree without Loader. + const settled = connectionCtx.get('loader')?.await() + if (settled === undefined) announceReady() + else { + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early + // SIGTERM); a URL line or browser tab for a dead server would only + // mislead, and reading torn-down services would turn a clean shutdown + // into a crash. + if (connectionCtx.get('webServer') !== undefined + && connectionCtx.get('connection') !== undefined) announceReady() + // Loader reports a failed boot; this row only stays quiet. + }, () => {}) + } + }) } } diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts index af6997cff7..9faad244dd 100644 --- a/packages/bundle/web-app/src/startup.ts +++ b/packages/bundle/web-app/src/startup.ts @@ -1,6 +1,6 @@ /** * The web app's command-line provider: it parses the `dsh --profile web` flag - * family (`--host`, `--port`, `--trusted-host`) and its `--help` + * family (`--host`, `--port`, `--trusted-host`, `--no-open`) and its `--help` * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. * Ordinary rows inject that service before reading it from lazy config. * @module @deepseek-ai/dsh-web-app/startup @@ -21,6 +21,8 @@ export const WEB_STARTUP_SERVICE = 'webStartup' /** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ export interface WebStartupValues { + /** Whether this invocation opens the default browser after startup. */ + openBrowser: boolean /** `--host`, absent when the invocation did not name one. */ host?: string /** `--port`, absent when the invocation did not name one. */ @@ -32,6 +34,7 @@ export interface WebStartupValues { /** The web flag family, as commander parsed it. */ interface WebOptions { host?: string + open: boolean port?: string trustedHost?: string[] } @@ -46,11 +49,13 @@ function webCommand(): Command { .description('Serve the DeepSeek Harness browser UI.') .helpOption('-h, --help', 'show this help') .option('--host ', 'bind host') + .option('--no-open', 'do not open the Web UI in the default browser') .option('--port ', 'listen port; pass 0 to let the OS pick a free one') .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .addHelpText('after', ` Examples: dsh --profile web serve on the composed host and port + dsh --profile web --no-open serve without opening a browser dsh --profile web --port 8080 serve on another port `) } @@ -73,6 +78,7 @@ export function apply(ctx: Context): void { program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) } ctx.provide(WEB_STARTUP_SERVICE, { + openBrowser: options.open, ...options.host !== undefined && { host: options.host }, ...options.port !== undefined && { port: Number(options.port) }, trustedHosts: options.trustedHost ?? [], diff --git a/packages/bundle/web-app/tests/browser-open.spec.ts b/packages/bundle/web-app/tests/browser-open.spec.ts new file mode 100644 index 0000000000..c9bc0c31cf --- /dev/null +++ b/packages/bundle/web-app/tests/browser-open.spec.ts @@ -0,0 +1,126 @@ +/** Default-browser startup over a real Loader tree and listening Web server. */ + +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import WebServer from '@deepseek-ai/dsh-host-webserver' +import { apply, internals } from '../src/index.ts' + +const contexts: Context[] = [] +const tempRoots: string[] = [] +const originalResolveDistIndex = internals.resolveDistIndex +const originalOpenBrowser = internals.openBrowser + +beforeEach(() => { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') +}) + +afterEach(async () => { + for (const ctx of contexts.splice(0)) await ctx.fiber.dispose() + for (const root of tempRoots.splice(0)) rmSync(root, { recursive: true, force: true }) + internals.resolveDistIndex = originalResolveDistIndex + internals.openBrowser = originalOpenBrowser + vi.unstubAllEnvs() + Reflect.deleteProperty(globalThis, '__dshWebAppApply') + Reflect.deleteProperty(globalThis, '__dshWebServer') + Reflect.deleteProperty(globalThis, '__dshConnection') +}) + +describe('web app browser startup', () => { + it('opens the canonical URL only after the complete page is reachable', async () => { + const root = mkdtempSync(join(tmpdir(), 'dsh-web-browser-open-')) + tempRoots.push(root) + const dist = join(root, 'dist') + mkdirSync(dist) + const index = join(dist, 'index.html') + writeFileSync(index, 'ready') + internals.resolveDistIndex = () => index + + const webserverModule = join(root, 'webserver.mjs') + const connectionModule = join(root, 'connection.mjs') + const webAppModule = join(root, 'web-app.mjs') + writeFileSync(webserverModule, 'export default globalThis.__dshWebServer\n') + writeFileSync(connectionModule, [ + "export const inject = ['webServer']", + "export const apply = ctx => ctx.provide('connection', globalThis.__dshConnection)", + '', + ].join('\n')) + writeFileSync(webAppModule, [ + "export const name = 'fixture-web-app'", + "export const inject = ['webServer']", + 'export const apply = (ctx, config) => globalThis.__dshWebAppApply(ctx, config)', + '', + ].join('\n')) + const config = join(root, 'cordis.yml') + writeFileSync(config, [ + '- id: webserver', + ` name: ${pathToFileURL(webserverModule).href}`, + ' config:', + ' host: 127.0.0.1', + ' port: 0', + '- id: connection', + ` name: ${pathToFileURL(connectionModule).href}`, + '- id: web-app', + ` name: ${pathToFileURL(webAppModule).href}`, + ' config:', + ' openBrowser: true', + ' printUrl: false', + ' surfaceContext: false', + ' trustedHosts: []', + '', + ].join('\n')) + + const globals = globalThis as unknown as { + __dshWebAppApply: typeof apply + __dshWebServer: typeof WebServer + __dshConnection: { + authenticatedUrl(baseUrl: string): string + authorizeIndex(): boolean + requestRejection(): undefined + rpc: object + } + } + globals.__dshWebAppApply = apply + globals.__dshWebServer = WebServer + globals.__dshConnection = { + authenticatedUrl: (baseUrl) => { + const url = new URL(baseUrl) + url.searchParams.set('token', 'fixture-token') + return url.href + }, + authorizeIndex: () => true, + requestRejection: () => undefined, + rpc: {}, + } + + let openedUrl: string | undefined + let openedStatus: number | undefined + let resolveOpened!: () => void + const opened = new Promise((resolve) => { resolveOpened = resolve }) + internals.openBrowser = async (url) => { + openedUrl = url + openedStatus = (await fetch(url)).status + resolveOpened() + } + + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(config).href }, + }) + await ctx.loader.await() + await opened + + expect(openedUrl).toBe(`http://127.0.0.1:${String(ctx.webServer.port)}/?token=fixture-token`) + expect(openedStatus).toBe(200) + }) +}) diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts index 26e347a503..ba3806232c 100644 --- a/packages/bundle/web-app/tests/startup.spec.ts +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -56,6 +56,7 @@ export const apply = ctx => globalThis.__webStartupApply(ctx) ` inject: [${WEB_STARTUP_SERVICE}]`, ' config:', " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' openBrowser: !!js ctx.webStartup.openBrowser', ' port: !!js ctx.webStartup.port ?? 3080', ' trustedHosts: !!js ctx.webStartup.trustedHosts', '- id: provider', @@ -89,12 +90,14 @@ describe('web command-line provider', () => { it('publishes each flag and releases direct service expressions', async () => { const { values, observed } = await bootProvider([ '--host', '127.0.0.1', + '--no-open', '--port', '8080', '--trusted-host', 'lab.internal', 'lab-2.internal', '--trusted-host', '10.0.0.9', ]) expect(values).toEqual({ host: '127.0.0.1', + openBrowser: false, port: 8080, trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], }) @@ -104,9 +107,10 @@ describe('web command-line provider', () => { it('leaves deployment values to each consumer when flags omit them', async () => { const { values, observed } = await bootProvider([]) - expect(values).toEqual({ trustedHosts: [] }) + expect(values).toEqual({ openBrowser: true, trustedHosts: [] }) expect(observed.readerConfig).toEqual({ host: '127.0.0.1', + openBrowser: true, port: 3080, trustedHosts: [], }) @@ -115,6 +119,7 @@ describe('web command-line provider', () => { it('prints its own help and leaves the consumer pending', async () => { const { values, observed } = await bootProvider(['--help']) expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--no-open') expect(observed.out).toContain('--trusted-host') expect(values).toBeUndefined() expect(observed.readerConfig).toBeUndefined() diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index a4e99e8e9b..40e47132ca 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -1,19 +1,28 @@ /** * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface - * prompt section and bash runtime variables, and URL-line printing with the - * runtime's bind-dependent LAN snapshot. + * prompt section and bash runtime variables, and readiness publication through + * the URL line and default-browser handoff. */ +import { EventEmitter } from 'node:events' +import { spawn, type ChildProcess } from 'node:child_process' import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { PassThrough } from 'node:stream' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' +import { createLaunchEnvironmentSnapshot, DSH_LAUNCH_ENVIRONMENT_KEY } from '@deepseek-ai/dsh-launch-environment' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { WebServer } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:child_process', async importOriginal => ({ + ...await importOriginal(), + spawn: vi.fn(), +})) + vi.mock('node:os', async importOriginal => ({ ...await importOriginal(), networkInterfaces: () => ({ @@ -24,14 +33,30 @@ vi.mock('node:os', async importOriginal => ({ let dist: string | undefined +beforeEach(() => { + vi.stubEnv('SSH_CONNECTION', '') + vi.stubEnv('SSH_TTY', '') +}) + afterEach(() => { vi.restoreAllMocks() + vi.mocked(spawn).mockReset() + vi.unstubAllEnvs() internals.resolveDistIndex = originalResolve + internals.openBrowser = originalOpenBrowser if (dist !== undefined) rmSync(dist, { recursive: true, force: true }) dist = undefined }) const originalResolve = internals.resolveDistIndex +const originalOpenBrowser = internals.openBrowser + +type BrowserLauncher = ChildProcess & { stderr: PassThrough } + +/** Minimal browser-launcher process for the native handoff adapter. */ +function launcher(): BrowserLauncher { + return Object.assign(new EventEmitter(), { stderr: new PassThrough() }) as unknown as BrowserLauncher +} /** Stage a dist fixture and point the bundle's resolver at it. */ function stageDist(): string { @@ -53,11 +78,26 @@ function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: fallback = handler return () => { fallback = undefined } }, - applyIndexTaps: (html: string) => html, + renderIndex: (html: string) => html, } as unknown as WebServer return { server, seat: () => fallback } } +/** Deterministic Host Connection face for URL publication and frontend injection. */ +function provideConnection(ctx: Context): void { + ctx.provide('connection', { + authenticatedUrl(baseUrl: string) { + const url = new URL(baseUrl) + url.pathname = '/' + url.searchParams.set('token', 'test-token') + return url.href + }, + authorizeIndex: () => true, + requestRejection: () => undefined, + rpc: {}, + } as never) +} + /** A fake Loader whose settlement the test controls (the URL line waits on it). */ function provideLoader(ctx: Context, settle: () => Promise = async () => {}): void { ctx.provide('loader', { await: settle } as never) @@ -70,11 +110,17 @@ interface BashContribution { } describe('web-app runtime glue', () => { - it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { + it('mounts dist serving, prompt section, bash variables, and publishes the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() + // Editor markers and a project .env SSH value do not establish a remote launch. + ctx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, createLaunchEnvironmentSnapshot([ + { source: 'process', values: { VSCODE_IPC_HOOK_CLI: '/tmp/local-vscode-ipc' } }, + { source: 'project-env', path: '/work/.env', values: { SSH_CONNECTION: 'stale-project-value' } }, + ])) const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('webServer', server) + provideConnection(ctx) const contributions: BashContribution[] = [] ctx.provide('shellEnv', { register: (contribution: BashContribution) => { @@ -83,8 +129,11 @@ describe('web-app runtime glue', () => { }, } as never) provideLoader(ctx) - const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) + const lifecycle: string[] = [] + const log = vi.spyOn(console, 'log').mockImplementation((message) => { lifecycle.push(String(message)) }) + const openBrowser = vi.fn(async (url: string) => { lifecycle.push(`open:${url}`) }) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) @@ -94,7 +143,14 @@ describe('web-app runtime glue', () => { lanAddresses: ['192.168.1.5'], trustedHosts: ['192.168.1.5', 'lab.internal'], }) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)') + expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token') + expect(lifecycle).toEqual([ + 'dsh web: http://127.0.0.1:4567/?token=test-token (LAN: http://192.168.1.5:4567/?token=test-token)', + 'dsh web: opening the default browser; pass --no-open to disable', + 'open:http://127.0.0.1:4567/?token=test-token', + ]) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') @@ -107,15 +163,19 @@ describe('web-app runtime glue', () => { await ctx.fiber.dispose() }) - it('stays quiet with printUrl off', async () => { + it('publishes no readiness side effect when printing and browser opening are disabled', async () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.find(entry => entry.name === 'app:web-surface')?.text) .toContain('rebuilding the affected Web artifacts') @@ -126,6 +186,7 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const contributions: BashContribution[] = [] ctx.provide('shellEnv', { register: (contribution: BashContribution) => { @@ -133,7 +194,7 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ printUrl: false, surfaceContext: false, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() @@ -147,58 +208,108 @@ describe('web-app runtime glue', () => { stageDist() const ctx = new Context() ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') await ctx.fiber.dispose() }) - it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { + it('does not publish readiness again when Connection reloads', async () => { stageDist() - // Settlement path: the line waits for loader.await() so supervisors can - // RPC immediately after observing it. + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + const first = ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) }) + await first + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + apply(ctx, new Config({ openBrowser: false, printUrl: true, surfaceContext: true, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledTimes(1) + + await first.dispose() + await ctx.plugin((connectionCtx: Context) => { provideConnection(connectionCtx) }) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledTimes(1) + await ctx.fiber.dispose() + }) + + it.each([ + ['SSH_CONNECTION', '10.0.0.2 55000 10.0.0.9 22'], + ['SSH_TTY', '/dev/pts/3'], + ] as const)('prints the host URL but skips browser handoff when %s marks an SSH launch', async (name, value) => { + vi.stubEnv(name, value) + stageDist() + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + apply(ctx, new Config({ openBrowser: true, printUrl: true, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') + expect(openBrowser).not.toHaveBeenCalled() + await ctx.fiber.dispose() + }) + + it('defers readiness publication until Loader settlement and drops it on failure or teardown', async () => { + stageDist() + const openBrowser = vi.fn(async () => {}) + internals.openBrowser = openBrowser + // Settlement path: both actions wait for loader.await() so their consumers + // can request the complete app immediately. const settled = new Context() settled.provide('webServer', fakeHttpServer().server) + provideConnection(settled) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) provideLoader(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(settled, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() release!() await new Promise(resolve => setTimeout(resolve, 0)) - expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') + expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567/?token=test-token') + expect(openBrowser).toHaveBeenCalledWith('http://127.0.0.1:4567/?token=test-token') await settled.fiber.dispose() // Failed path: Loader reports the sibling failure; the app prints no URL // for a process that is about to exit. log.mockClear() + openBrowser.mockClear() const failed = new Context() failed.provide('webServer', fakeHttpServer().server) + provideConnection(failed) provideLoader(failed, async () => { throw new Error('boot failed') }) - apply(failed, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(failed, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() await failed.fiber.dispose() // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() + openBrowser.mockClear() const torn = new Context() const child = torn.plugin((childCtx: Context) => { childCtx.provide('webServer', fakeHttpServer().server) + provideConnection(childCtx) }) await child let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) provideLoader(torn, () => tornSettlement) - apply(torn, new Config({ printUrl: true, surfaceContext: true, trustedHosts: [] })) + apply(torn, new Config({ openBrowser: true, printUrl: true, surfaceContext: true, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) await child.dispose() // the webServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() + expect(openBrowser).not.toHaveBeenCalled() await torn.fiber.dispose() }) @@ -210,22 +321,98 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('webServer', server) - apply(ctx, new Config({ printUrl: false, surfaceContext: true, trustedHosts: [] })) + provideConnection(ctx) + apply(ctx, new Config({ openBrowser: false, printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('webServer service missing') await ctx.fiber.dispose() }) - it('resolves the real built frontend dist through the package exports, failing loud unbuilt', () => { - // The production resolver (not the test hook). A built checkout resolves - // the frontend package's index.html; a dist-less one (the CI coverage - // lane runs before any build) must fail with the build hint, never a - // silent fallback. - try { - expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) - } catch (error) { - expect((error as Error).message).toContain('frontend dist not built') - } + it('anchors the dist index on the frontend package manifest without requiring a built dist', () => { + // The production resolver (not the test hook): the anchor resolves on any + // checkout, built or not — dist existence is the fallback owner's + // request-time concern, so a dist-less composition (the static worker + // preview ships its own page) still boots. + expect(originalResolve()).toMatch(/dist[/\\]index\.html$/) + }) + + it.each([ + ['Error', new Error('no desktop'), 'no desktop'], + ['non-Error', 'desktop unavailable', 'desktop unavailable'], + ] as const)('keeps the server running and reports the manual URL when a browser failure is %s', async (_kind, failure, reason) => { + stageDist() + const ctx = new Context() + ctx.provide('webServer', fakeHttpServer().server) + provideConnection(ctx) + internals.openBrowser = vi.fn(async () => { throw failure }) + const log = vi.spyOn(console, 'log').mockImplementation(() => {}) + const diagnostic = vi.spyOn(console, 'error').mockImplementation(() => {}) + apply(ctx, new Config({ openBrowser: true, printUrl: false, surfaceContext: false, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).toHaveBeenCalledWith('dsh web: opening the default browser; pass --no-open to disable') + expect(diagnostic).toHaveBeenCalledWith( + `web-app: could not open the default browser because ${reason}; use the dsh web URL printed at startup`, + ) + expect(ctx.get('webServer')).toBeDefined() + await ctx.fiber.dispose() + }) + + it('scrubs the helper environment and reports helper spawn or exit failures', async () => { + vi.stubEnv('DEEPSEEK_API_KEY', 'must-not-reach-browser') + vi.stubEnv('DSH_HOME', '/must-not-reach-browser') + const completed = launcher() + vi.mocked(spawn).mockReturnValueOnce(completed) + const completion = originalOpenBrowser('http://127.0.0.1:4567') + const [command, args, options] = vi.mocked(spawn).mock.calls[0]! + expect(command).toBe(process.execPath) + expect(args).toEqual([ + '--input-type=module', + '--eval', expect.stringContaining('await import('), + '--', 'http://127.0.0.1:4567', + ]) + expect(args?.[2]).toContain("if (process.platform === 'win32')") + expect(args?.[2]).toContain('launcher.ref()') + expect(options?.env).not.toHaveProperty('DEEPSEEK_API_KEY') + expect(options?.env).not.toHaveProperty('DSH_HOME') + expect(options?.env?.PATH).toBe(process.env.PATH) + expect(options?.stdio).toEqual(['ignore', 'inherit', 'pipe']) + completed.emit('close', 0) + await expect(completion).resolves.toBeUndefined() + expect(completed.listenerCount('error')).toBe(0) + + const completedWithStderr = launcher() + vi.mocked(spawn).mockReturnValueOnce(completedWithStderr) + const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const completionWithStderr = originalOpenBrowser('http://127.0.0.1:4567') + completedWithStderr.stderr?.write('launcher note\n') + completedWithStderr.emit('close', 0) + await expect(completionWithStderr).resolves.toBeUndefined() + expect(stderr).toHaveBeenCalledWith('launcher note\n') + + const failedWithReason = launcher() + vi.mocked(spawn).mockReturnValueOnce(failedWithReason) + const reasonFailure = originalOpenBrowser('http://127.0.0.1:4567') + const reasonAssertion = expect(reasonFailure).rejects.toThrow('desktop unavailable') + failedWithReason.stderr?.write('Error: desktop unavailable\n at fixture') + failedWithReason.emit('close', 1) + await reasonAssertion + + const failed = launcher() + vi.mocked(spawn).mockReturnValueOnce(failed) + const failure = originalOpenBrowser('http://127.0.0.1:4567') + const failureAssertion = expect(failure).rejects.toThrow('exited with code 3') + await Promise.resolve() + failed.emit('close', 3) + await failureAssertion + + const errored = launcher() + vi.mocked(spawn).mockReturnValueOnce(errored) + const error = originalOpenBrowser('http://127.0.0.1:4567') + const errorAssertion = expect(error).rejects.toThrow('spawn failed') + await Promise.resolve() + errored.emit('error', new Error('spawn failed')) + await errorAssertion + expect(errored.listenerCount('close')).toBe(0) }) }) diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 77d823538c..2f8cc6c3b2 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -23,18 +23,27 @@ { "path": "../../boot/cmdline" }, + { + "path": "../../client/connection/tsconfig.host.json" + }, { "path": "../../host/frontend-static" }, { "path": "../../host/webserver" }, + { + "path": "../../util/launch-environment" + }, { "path": "../../core/system-prompt" }, { "path": "../../shell/shell-env" }, + { + "path": "../../subprocess/subprocess" + }, { "path": "../../runtime-diagnostics/invariants" } diff --git a/packages/client/AGENTS.md b/packages/client/AGENTS.md index 86a23bbf12..27f1761cea 100644 --- a/packages/client/AGENTS.md +++ b/packages/client/AGENTS.md @@ -1,12 +1,12 @@ # AGENTS.md — Web client stack -Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Before touching slots, component props, stores, or plugin structure, read the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) (the definitive composition model) and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) (loading chain, object layer, services). +Rules for `packages/client/*` (the browser side of the dsh web GUI) plus its build entry `apps/web`. They supplement the repo-wide [conventions](../../AGENTS.md#conventions) and the [package rules](../README.md). Read the current [Web Client architecture](../../docs/subsystems/web-client.md), [Slots reference](../../docs/subsystems/slots.md), and [Conversation reference](../../docs/subsystems/conversation.md) before changing the corresponding layer. Packages here are named with the directory prefix: `@deepseek-ai/dsh-client-`. ## Slot and props discipline -The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) owns the full design; these are the rules you must not violate when writing or reviewing client code: +The [Slots reference](../../docs/subsystems/slots.md) owns the current design; these are the rules you must not violate when writing or reviewing client code: 1. **One API**: a plugin composes UI only through `ctx.slots.register({ name, children?, store?, inject? }, Component)`. There is no separate slot-definition call, no whitelist face object, no face-minting helper. The shell alone renders `'root'`. 2. **children = declaration + authorization**: the slots your component renders are exactly the keys of your register call's `children` object (spec values: `kind`/`scope`). Rendering a slot you didn't declare, or declaring one someone else declared, fails at load — do not work around it; the conflict is the design speaking. Slot names mirror the composition path: `..` (e.g. `'tool.call.toolview'`). @@ -33,7 +33,7 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a 1. **A UI plugin exports no values beyond what cordis loading needs** — `apply` / `inject` (and `Config` where present), plus store factories consumed type-only by components (`ReturnType`). Shared types (owner data, injected values, composed prop aliases) may also be exported. Implementation components, pure helpers, constants, and store handles stay internal. Adding any new value export requires user sign-off, not a matching consumer. 2. **Same-package tests import internals directly** — relative `../src/client/xxx.ts` from package tests, or the `./src/*` subpath where a spec lives outside the package. Never widen the public API to make a test compile. -3. **Cross-package imports of another plugin's symbols are in principle forbidden.** The sanctioned routes are the slot system (register/renderSlot) and ctx services. If neither fits, stop and escalate — do not add an export to unblock yourself. +3. **A feature plugin MUST NOT runtime-import or re-export another feature plugin's values, and MUST NOT declare `dsh.client.external` to obtain them.** Shared declarations use `import type`; behavior crosses packages through injected Cordis services, and UI crosses packages through slots. If neither fits, stop and escalate — do not add an export to unblock yourself. Shared runtime code belongs only in a narrow static owner such as `client/store`, `ui-primitives`, or a browser-safe utility package; transport and generated API assemblies keep their explicit infrastructure edges. ## ctx discipline (components never see ctx) @@ -41,9 +41,9 @@ The `/client` entrypoint of a UI plugin package is its public browser API, not a ## Layering red lines -The stack has one-way knowledge, settled in the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md): +The stack has one-way knowledge, documented in the [Web Client architecture](../../docs/subsystems/web-client.md): -1. **Data object layer** (`runtime`, React-free): `ConnectionController` → `SessionManager` → `Session` own all business state (event windows, streaming accumulation, reconnect machine), and the snapshot-store engine (zustand/immer, `defineStore`, `shallowEqual`) lives here too — store products are bare observable sources with no hook members. Zero React imports — grep-assertable. +1. **Data object layer** (React-free): `client/connection` owns transport generations, `api/session-controller/client` owns `ClientSessions` → `SessionManager` → `Session`, `api/workspace-controller/client` owns Workspace state, and `client/store` owns the snapshot-store engine (`defineStore`, `createSnapshotStore`, `shallowEqual`). Store products are bare observable sources with no hook members. 2. **Render machinery** (`ui-renderer`, dynamic plugin): all ctx-to-React integration — slot renderer/outlets, `SessionProvider`, and the uSES adapter. Every hook is composed here at the binding site from bare sources; production business code carries no ui-renderer value dependency. 3. **Presentation components** (plugin packages' `src/client/`, pure props): consumables, expected to be rewritten wholesale. Business logic must not leak into them; everything arrives through the four props shares. @@ -51,8 +51,8 @@ Non-negotiables across the layers: - **Business data lives in the object layer, never a store.** Entry-declared stores carry shared viewing/interaction state (selection, drafts, panel widths); sessions, frames, and connections stay in the object layer. - **rpcId is strictly bidirectional**: the initiator mints, the responder echoes; business signatures see only `RpcRequest

`, minting stays in the carrier layer ([layering and RPC protocol note](../../.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md)). -- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `runtime/src/client/sessions/notifier.ts`. -- **The web layer is pure presentation.** Nothing that is "how to draw" (tool-card views, queue states) enters the session log; the host computes such data per frame or pushes it live, and replay recomputes it — falling back to the generic form when it can't. A new *model-visible* input still requires a session event (repo-wide rule). +- **Notifier publication discipline**: `notifyNow` is only the direct echo of a user gesture; structural updates use microtask-batched `markDirty`, while visible streaming chunks use cumulative `markFrameDirty`. See `../api/session-controller/src/client/sessions/notifier.ts`. +- **The web layer is pure presentation.** Nothing that is only "how to draw" enters the session log. Tool cards derive in the Client from raw call/result events and persisted result metadata; process-local control state uses its own snapshots and frames. Unknown or malformed tool data falls back to the generic form. A new *model-visible* input still requires a session event (repo-wide rule). ## Dependency declaration @@ -66,12 +66,16 @@ Npm sections describe installation and development relationships; each build fac 6. **Browser and Node build faces declare externality independently.** A dynamic browser half uses the baseline plus `dsh.client.external`; a statically linked face externalizes every bare specifier; a Node face externalizes its production dependencies ([`tsdown.client.ts`](tsdown.client.ts)). Moving a name between npm sections must not silently change bundle contents. 7. **Keep the published payload closed.** Every relative runtime import and emitted asset must be covered by `files`; the repository publint pass checks the exact publication view. +## Build-time browser environment + +Client business code may statically read `process.env.DSH_CLIENT_*`; every referenced value is public artifact content. The shared build-environment helper gives Vite and dynamic tsdown bundles the same build-process values, resolves unset names to `undefined`, and exposes no dynamic lookup or enumeration. A complete root build records the exact public values and a digest of all client artifacts; release and built-artifact consumers reject a missing or stale record. Use runtime configuration for choices that must change after build. + ## Shared modules and the module graph -A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static UI libraries; `PRELOADED_CLIENT_EXTERNALS` names dynamic rows, currently runtime, whose ordinary `lib/client.js` factory arrives before shell boot. +A dynamic browser half either carries a module privately or requests the shared module-table identity. The client baseline is centralized in [`web/src/platform.ts`](web/src/platform.ts): `PLATFORM_MODULES` names shell-seeded React, Cordis, and static Client libraries; `PRELOADED_CLIENT_EXTERNALS` is reserved for dynamic rows whose factories must arrive before shell boot and is empty when no such row exists. -1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, runtime, `ui-primitives`, or `ui-slots` in package manifests. -2. **`dsh.client.external` adds a package-specific request.** Use it only for a non-baseline value import whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. +1. **Baseline externals are implicit for every dynamic bundle.** Do not repeat React, Cordis, `client/store`, `ui-primitives`, or `ui-slots` in package manifests. +2. **`dsh.client.external` is not a feature-plugin dependency mechanism.** Only infrastructure, transport, or generated assembly may add a package-specific non-baseline value request whose dynamic row must be materialized through the module table. Declare the exact import specifier; only a trailing `/client` aliases the package row. 3. **Silence means a private copy.** Ordinary third-party implementation libraries may be bundled independently. A value reached only through `import type` is erased and creates no request. 4. **A request has two possible suppliers.** A dynamic package supplies its own row; `PLATFORM_MODULES` supplies an exact static-table key. There is no `dsh.client.provide` alias protocol. 5. **Validate both sides.** The dynamic build preset externalizes the baseline and rejects undeclared workspace value imports; [`verify-client-packages`](../../scripts/verify-client-packages.ts) rejects malformed or redundant requests, missing suppliers, and synchronous request cycles. @@ -94,17 +98,19 @@ The seam is `loader.internal = modules`: cordis reaches plugin code through `Ent ## Conversation Node discipline -- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation Node cookbook](../../docs/cookbook/adding-a-conversation-node.md). -- `match(event)` reads only the current event. Every event in a multi-event Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by log `seq`. +- A Chat business feature registers one `ConversationNodeDefinition` and its keyed `conversation.chat.node` renderer; do not add its event switch or fold to `Session`, `SessionManager`, or a central built-in dispatcher. Follow the [Conversation reference](../../docs/subsystems/conversation.md). +- `match(event)` reads only the current `SessionEventLike`. Every scalar event or packed Assistant run in a multi-input Context carries or independently derives the same stable business id; `update` folds one Match into State and remains deterministically replayable by logical log `seq`. Packed rows are update-only, and a Definition that consumes Assistant deltas implements both scalar and `chunkrow/*` branches without expanding members. - The append hot path and renderers never scan the full event window, Contexts, or Chat Nodes. Accumulate in State, publish same-Turn/Step facts through `buildLocationData()`, and consume final Node data or constrained Location hooks. ## Directory regime (plugin packages) One UI feature = one plugin package (`src/client/` browser half). A multi-domain package splits where its code could later become separate packages — ui-conversation is the example: `contract/` (the only shared API), domain directories that never import a sibling domain, and `apply.ts` as the single cross-domain assembly point; `scripts/verify-client-domain-graph.ts` enforces the levels. Registration goes through `slots.register` in `apply` — never module-level side effects. -## Styling +## Styling and localization -[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Product copy is Chinese; code comments are English. +[docs/web-styling.md](../../docs/web-styling.md) is authoritative. Shared `--dsw-*` tokens and global sheets live in `ui-theme/src/styles/`; feature components consume semantic aliases through CSS Modules and `clsx`, with no literal colors, component library, or Tailwind. Code comments are English. + +Every product-visible string—including text, accessibility names, tooltips, placeholders, status/unit formatters, and primitive chrome—lives in a typed locale dictionary and reaches components through the standard `t` seat or an already-localized prop. Cordis-free primitives require complete label props and own no fallback copy. Keep user/model/wire data and code tokens verbatim; internal matching uses discriminants or stable ids, never localized text. `pnpm run verify-client-ui-i18n` enforces source ownership ([decision](../../.agents/notes/implemented/architecture/2026-08-23-locale-owned-client-ui-copy.md)). ## Testing and coverage @@ -138,9 +144,9 @@ Bringing up a new `packages/client/` plugin package (ui-workspace is a com ## New component checklist -1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md). No other composition route exists. +1. Compose through register: add the slot to `SlotMap`, declare it in its parent entry's `children`, and register your component — see the [Slots reference](../../docs/subsystems/slots.md). No other composition route exists. 2. Type the props as the four shares (`PropsRuntime` & `PropsRenderSlots` & `PropsStore` & inject face) — derive, don't hand-write. Shared/surviving state goes in a `createXXXStore()` factory declared at register; component-private state stays local. 3. Component tests feed props directly (`createXXXStore().create()` for the store data; plain stubs for framework hooks) and assert behavior without render machinery. -4. Tokens only in CSS; Chinese product copy; English comments. +4. Tokens only in CSS; product copy follows the localization rule above; English comments. 5. `pnpm run test:gui` green; if the component changes visible assembled output, also run `DSH_SNAPSHOT=replay pnpm run test:web`. 6. Non-trivial change? It needs an Agent Note in the same PR (repo-wide rule) — the GUI notes above are the precedents to extend. diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index a1de337e80..9a60503d9c 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/README.md -README.md: cff74ddb048df8b37643f4c44f01e2bda4160a77 -README.zh.md: 5e5d623509b9152fb7b59ea228cfa42720b737af +README.md: 51661c58b40d8fe1c9cde161871845c5898c1ac2 +README.zh.md: 6e7f5351442f29a34096ebaede9ca1b4e45b5940 diff --git a/packages/client/README.md b/packages/client/README.md index cff74ddb04..51661c58b4 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -1,47 +1,94 @@ +--- +description: "Package map for the web GUI browser half: shell boot, browser-host communication, shared client services, localization, development reload, and the UI feature plugins." +kind: "package-group" +--- + # client/ — web-GUI browser half English | [中文](README.zh.md) -The browser side of the dsh web GUI: shell boot, browser-host communication, shared UI services, and feature plugins. Authoring rules live in [AGENTS.md](AGENTS.md); the host half is [`host/`](../host/README.md). All except `test-runtime` are **product** packages named `@deepseek-ai/dsh-client-`. +## Summary -| Package | Purpose | -|---|---| -| [`web/`](web/README.md) | Boots the browser shell from the client entry graph. | -| [`ui-renderer/`](ui-renderer/README.md) | Binds slot data to React and mounts the assembled application after client boot settles. | -| [`modules/`](modules/README.md) | Loads browser-side client modules. | -| [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery. | -| [`runtime/`](runtime/README.md) | Provides shared client services for sessions, workspaces, and UI composition. | -| [`hmr/`](hmr/README.md) | Refreshes client plugins during development. | -| [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries. | -| [`test-runtime/`](../test-support/client-runtime/README.md) | Provides shared repository test support for client feature packages. | -| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots. | -| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme. | -| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers. | -| [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation. | -| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions. | -| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation. | -| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces. | -| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface. | -| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views. | -| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested Chat disclosures with live-only child navigation. | -| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal. | -| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity. | -| [`ui-commands/`](ui-commands/README.md) | Provides session-aware command discovery and dispatch. | -| [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions. | -| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | -| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | -| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header. | -| [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces. | -| [`ui-permission/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access. | -| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | -| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards. | -| [`ui-user-questions/`](ui-user-questions/README.md) | Presents interactive questions requested by the agent. | -| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. | -| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. | -| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. | -| [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. | -| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings. | +The `client/` group runs the browser half of the dsh web GUI: it boots the web shell, loads browser-side plugin modules, keeps browser-to-host RPC and event delivery alive, and provides the shared client services and UI feature plugins that render the application. UI features compose through the slot system — each plugin fills declared extension slots with typed props and stores, and the shell renders the assembled tree. All packages here are product packages named `@deepseek-ai/dsh-client-`; the host half that serves the page lives in [`host/`](../host/README.md). Authoring rules live in [AGENTS.md](AGENTS.md), and the module graph, slot model, and object layer are documented in the related notes below. -Each child reference owns its contract and detailed behavior. The [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) and [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) own the cross-package composition and loading decisions. +## Table of Contents -The subsystem reference is [client-modules.md](../../docs/subsystems/client-modules.md); the [slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) is the definitive slot model, and the [web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) owns the loading chain and object layer. +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +The kernel packages boot and serve the page; the UI feature packages present it. Each package README owns its contract and configuration. + +| Package | Role | ctx key | +|---|---|---| +| [`web/`](web/README.md) | Boots the browser shell | — | +| [`modules/`](modules/README.md) | Loads browser-side client modules | `ctx.clientModules` / `ctx.modules` | +| [`connection/`](connection/README.md) | Maintains browser-host RPC communication and event delivery | `ctx.connection` | +| [`store/`](store/README.md) | Provides React-free observable and snapshot-store primitives | — | +| [`hmr/`](hmr/README.md) | Refreshes client plugins during development | — | +| [`locale/`](locale/README.md) | Provides localization preferences and message dictionaries | `ctx.locale` | +| [`test-runtime/`](../test-support/client-runtime/README.md) | Shared repository test support for client feature packages | — | +| [`ui-renderer/`](ui-renderer/README.md) | Binds slot data to React and mounts the assembled application | `ctx.uiRenderer` | +| [`ui-slots/`](ui-slots/README.md) | Defines how UI features register and compose extension slots | — | +| [`ui-session/`](ui-session/README.md) | Adapts Session Controller state into standard Slot sources and hooks | — | +| [`ui-theme/`](ui-theme/README.md) | Applies the selected color theme | — | +| [`ui-primitives/`](ui-primitives/README.md) | Provides shared React controls, icons, and content renderers | — | +| [`ui-attachment/`](ui-attachment/README.md) | Registers composer and message-image attachment presentation | — | +| [`ui-layout/`](ui-layout/README.md) | Arranges the main application regions | — | +| [`ui-sidebar/`](ui-sidebar/README.md) | Presents workspace and session navigation | — | +| [`ui-brand-official/`](ui-brand-official/README.md) | Fills the generic browser-brand slots with the official name and marks | — | +| [`ui-workspace/`](ui-workspace/README.md) | Provides workspace selection and creation surfaces | — | +| [`ui-conversation/`](ui-conversation/README.md) | Presents the active conversation and its input surface | — | +| [`ui-chat/`](ui-chat/README.md) | Projects and renders the Chat conversation target | — | +| [`ui-approval/`](ui-approval/README.md) | Presents approval requests and returns user decisions | — | +| [`ui-tool/`](ui-tool/README.md) | Composes Tool call trees and keyed per-Tool views | — | +| [`ui-workflow-run/`](ui-workflow-run/README.md) | Replays durable workflow runs as nested chat disclosures | — | +| [`ui-goal/`](ui-goal/README.md) | Presents and manages the current goal | — | +| [`ui-trajectory/`](ui-trajectory/README.md) | Presents alternate views of agent activity | — | +| [`ui-commands/`](ui-commands/README.md) | Provides session-aware command discovery and dispatch | — | +| [`ui-input-trigger/`](ui-input-trigger/README.md) | Coordinates inline command and reference suggestions | — | +| [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions | — | +| [`ui-reference/`](ui-reference/README.md) | Unified Web `@file` / `@session` reference source | — | +| [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references | — | +| [`ui-jobs/`](ui-jobs/README.md) | Lists this session's background jobs in the conversation header | — | +| [`ui-model-selection/`](ui-model-selection/README.md) | Provides model selection in conversation surfaces | — | +| [`ui-permission-presets/`](ui-permission-presets/README.md) | Configures default permissions and switches the current session's access | — | +| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control | — | +| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | Owns the Plugins settings section, its tab extension point, and configurable host-plane plugin cards | — | +| [`ui-user-questions/`](ui-user-questions/README.md) | Presents interactive questions requested by the agent | — | +| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions | — | +| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas | — | +| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section | — | +| [`ui-settings-models/`](ui-settings-models/README.md) | Provides model-provider configuration and DeepSeek onboarding | — | +| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | Contributes the read-only Host Loader inventory tab to Plugins settings | — | +| [`ui-deliverables/`](ui-deliverables/README.md) | Produces the produced-files turn tail and clickable final-response file references | — | +| [`ui-message-feedback/`](ui-message-feedback/README.md) | Contributes per-message feedback controls to the assistant-message action strip | — | +| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.md) | In-app directory browsing surface for the workspace directory flow | — | +| [`ui-directory-picker-native/`](ui-directory-picker-native/README.md) | Native directory-picker surface driving the host's OS chooser | — | + +----- + + +## Related documentation + +Start with the subsystem reference and the two notes that own the cross-package composition decisions, then the host half that serves this page. + +- [Client modules subsystem](../../docs/subsystems/client-modules.md) — the web plugin table: `dsh.client` declarations, the boot graph wire, and the bundle route. +- [Slot system standard](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md) — the definitive slot model: registration, props shares, and stores. +- [Web client architecture note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md) — the loading chain, object layer, and client services. +- [Host group map](../host/README.md) — the host half that serves this browser half. + + +## Dev Note + +

+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index 5e5d623509..6e7f535144 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -1,47 +1,94 @@ -# client/ — web GUI 浏览器端 +--- +description: "web GUI 浏览器侧的包映射:外壳启动、浏览器与宿主通信、共享客户端服务、本地化、开发重载与 UI 功能插件。" +kind: "package-group" +--- + +# client/ — Web GUI 浏览器侧 [English](README.md) | 中文 -dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 UI 服务和功能插件。编写规则见 [AGENTS.md](AGENTS.md);宿主半侧是 [`host/`](../host/README.md)。除 `test-runtime` 外,均为名为 `@deepseek-ai/dsh-client-` 的**产品**包。 +## 概述 -| 包 | 目的 | -|---|---| -| [`web/`](web/README.md) | 从客户端条目图启动浏览器 shell。 | -| [`ui-renderer/`](ui-renderer/README.md) | 将 slot 数据绑定到 React,并在客户端启动稳定后挂载组装完成的应用。 | -| [`modules/`](modules/README.md) | 加载浏览器侧客户端模块。 | -| [`connection/`](connection/README.md) | 维护浏览器与宿主之间的 RPC 通信和事件传递。 | -| [`runtime/`](runtime/README.md) | 为会话、工作区和 UI 组合提供共享客户端服务。 | -| [`hmr/`](hmr/README.md) | 在开发期间刷新客户端插件。 | -| [`locale/`](locale/README.md) | 提供本地化偏好与消息词典。 | -| [`test-runtime/`](../test-support/client-runtime/README.md) | 为客户端功能包提供共享的仓库测试支持。 | -| [`ui-slots/`](ui-slots/README.md) | 定义 UI 功能注册和组合扩展 slot 的方式。 | -| [`ui-theme/`](ui-theme/README.md) | 应用所选颜色主题。 | -| [`ui-primitives/`](ui-primitives/README.md) | 提供共享 React 控件、图标和内容渲染器。 | -| [`ui-attachment/`](ui-attachment/README.md) | 注册输入框与消息图片的附件呈现。 | -| [`ui-layout/`](ui-layout/README.md) | 排列应用的主要区域。 | -| [`ui-sidebar/`](ui-sidebar/README.md) | 展示工作区与会话导航。 | -| [`ui-workspace/`](ui-workspace/README.md) | 提供工作区选择与创建界面。 | -| [`ui-conversation/`](ui-conversation/README.md) | 展示当前对话及其输入界面。 | -| [`ui-tool/`](ui-tool/README.md) | 编排工具调用树和按工具键控的视图。 | -| [`ui-workflow-run/`](ui-workflow-run/README.md) | 把持久工作流运行回放为 Chat 嵌套折叠项,并只为实时子 Session 提供导航。 | -| [`ui-goal/`](ui-goal/README.md) | 展示和管理当前目标。 | -| [`ui-trajectory/`](ui-trajectory/README.md) | 提供 agent(智能体)活动的其他视图。 | -| [`ui-commands/`](ui-commands/README.md) | 提供会话感知的命令发现与分发。 | -| [`ui-input-trigger/`](ui-input-trigger/README.md) | 协调内联命令和引用建议。 | -| [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | -| [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent(子 agent)导航、子级 transcript(文本记录)的状态和内联引用。 | -| [`ui-jobs/`](ui-jobs/README.md) | 在会话标题栏列出当前会话的后台任务。 | -| [`ui-model-selection/`](ui-model-selection/README.md) | 在对话界面中提供模型选择。 | -| [`ui-permission/`](ui-permission-presets/README.md) | 配置默认权限并切换当前会话的访问模式。 | -| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | -| [`ui-settings-plugins/`](ui-settings-plugins/README.md) | 拥有“插件”设置分区、它的标签页扩展点,以及可配置的宿主平面插件卡片。 | -| [`ui-user-questions/`](ui-user-questions/README.md) | 展示 agent 请求的交互式问题。 | -| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并编写预设组合。 | -| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 | -| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 | -| [`ui-settings-models/`](ui-settings-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 | -| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页。 | +`client/` 组运行 dsh web GUI 的浏览器侧:它启动 web 外壳、加载浏览器侧插件模块、维持浏览器与宿主之间的 RPC 与事件投递,并提供渲染应用所需的共享客户端服务与 UI 功能插件。UI 功能通过 slot 系统组合——每个插件填充已声明的扩展 slot,携带类型化 props 与 store,由外壳渲染组装后的整棵树。本组所有包均为产品包,名为 `@deepseek-ai/dsh-client-`;服务于页面的宿主半侧位于 [`host/`](../host/README.zh.md)。编写规则见 [AGENTS.md](AGENTS.md),模块图、slot 模型与对象层的说明见下方相关文档。 -每个子文档负责自身的约定和详细行为。[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)与 [Web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)负责跨包组合与加载决策。 +## 目录 -子系统参考是 [client-modules.md](../../docs/subsystems/client-modules.md);[slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.md)是权威 slot 模型,[web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.md)拥有加载链与对象层。 +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +内核包负责启动与服务于页面,UI 功能包负责呈现页面。各包的 README 拥有自己的约定与配置。 + +| 包 | 职责 | ctx 键 | +|---|---|---| +| [`web/`](web/README.zh.md) | 启动浏览器外壳 | — | +| [`modules/`](modules/README.zh.md) | 加载浏览器侧客户端模块 | `ctx.clientModules` / `ctx.modules` | +| [`connection/`](connection/README.zh.md) | 维护浏览器与宿主之间的 RPC 通信与事件投递 | `ctx.connection` | +| [`store/`](store/README.zh.md) | 提供不依赖 React 的 observable 与 snapshot-store 原语 | — | +| [`hmr/`](hmr/README.zh.md) | 在开发期间刷新客户端插件 | — | +| [`locale/`](locale/README.zh.md) | 提供本地化偏好与消息词典 | `ctx.locale` | +| [`test-runtime/`](../test-support/client-runtime/README.zh.md) | 为客户端功能包提供共享的仓库测试支持 | — | +| [`ui-renderer/`](ui-renderer/README.zh.md) | 将 slot 数据绑定到 React,并挂载组装完成的应用 | `ctx.uiRenderer` | +| [`ui-slots/`](ui-slots/README.zh.md) | 定义 UI 功能注册与组合扩展 slot 的方式 | — | +| [`ui-session/`](ui-session/README.zh.md) | 把 Session Controller 状态适配为标准 Slot source 与 hook | — | +| [`ui-theme/`](ui-theme/README.zh.md) | 应用所选颜色主题 | — | +| [`ui-primitives/`](ui-primitives/README.zh.md) | 提供共享 React 控件、图标与内容渲染器 | — | +| [`ui-attachment/`](ui-attachment/README.zh.md) | 注册输入框与消息图片的附件呈现 | — | +| [`ui-layout/`](ui-layout/README.zh.md) | 排列应用的主要区域 | — | +| [`ui-sidebar/`](ui-sidebar/README.zh.md) | 展示工作区与会话导航 | — | +| [`ui-brand-official/`](ui-brand-official/README.zh.md) | 用官方名称与标记填充通用浏览器品牌 slot | — | +| [`ui-workspace/`](ui-workspace/README.zh.md) | 提供工作区选择与创建界面 | — | +| [`ui-conversation/`](ui-conversation/README.zh.md) | 展示当前对话及其输入界面 | — | +| [`ui-chat/`](ui-chat/README.zh.md) | 投影并渲染 Chat 对话 target | — | +| [`ui-approval/`](ui-approval/README.zh.md) | 展示批准请求并返回用户决策 | — | +| [`ui-tool/`](ui-tool/README.zh.md) | 编排工具调用树与按工具键控的视图 | — | +| [`ui-workflow-run/`](ui-workflow-run/README.zh.md) | 把持久工作流运行回放为嵌套对话折叠项 | — | +| [`ui-goal/`](ui-goal/README.zh.md) | 展示与管理当前目标 | — | +| [`ui-trajectory/`](ui-trajectory/README.zh.md) | 提供 agent(智能体)活动的其他视图 | — | +| [`ui-commands/`](ui-commands/README.zh.md) | 提供会话感知的命令发现与分发 | — | +| [`ui-input-trigger/`](ui-input-trigger/README.zh.md) | 协调内联命令与引用建议 | — | +| [`ui-skill/`](ui-skill/README.zh.md) | 向内联建议添加 skill(技能)引用 | — | +| [`ui-reference/`](ui-reference/README.zh.md) | 统一的 Web `@file` / `@session` 引用 source | — | +| [`ui-subagent/`](ui-subagent/README.zh.md) | 提供 subagent(子智能体)导航、子级 transcript(文本记录)状态与内联引用 | — | +| [`ui-jobs/`](ui-jobs/README.zh.md) | 在会话标题栏列出当前会话的后台任务 | — | +| [`ui-model-selection/`](ui-model-selection/README.zh.md) | 在对话界面中提供模型选择 | — | +| [`ui-permission-presets/`](ui-permission-presets/README.zh.md) | 配置默认权限并切换当前会话的访问模式 | — | +| [`ui-plan/`](ui-plan/README.zh.md) | 展示生效中的 plan mode 状态及其退出控件 | — | +| [`ui-settings-plugins/`](ui-settings-plugins/README.zh.md) | 拥有“插件”设置分区、其标签页扩展点与可配置的宿主平面插件卡片 | — | +| [`ui-user-questions/`](ui-user-questions/README.zh.md) | 展示 agent 请求的交互式问题 | — | +| [`ui-agent-preset/`](ui-agent-preset/README.zh.md) | 选择会话的 agent 预设并编写预设组合 | — | +| [`ui-settings/`](ui-settings/README.zh.md) | 承载设置界面及其扩展区域 | — | +| [`ui-settings-general/`](ui-settings-general/README.zh.md) | 提供常规设置分区 | — | +| [`ui-settings-models/`](ui-settings-models/README.zh.md) | 提供模型提供方配置与 DeepSeek 引导 | — | +| [`ui-settings-plugin-inventory/`](ui-settings-plugin-inventory/README.zh.md) | 向“插件”设置贡献只读的 Host Loader 清单标签页 | — | +| [`ui-deliverables/`](ui-deliverables/README.zh.md) | 生成已产出文件的轮次尾部与可点击的最终响应文件引用 | — | +| [`ui-message-feedback/`](ui-message-feedback/README.zh.md) | 向助手消息操作条贡献逐消息反馈控件 | — | +| [`ui-directory-picker-browse/`](ui-directory-picker-browse/README.zh.md) | 面向工作区目录流程的应用内目录浏览界面 | — | +| [`ui-directory-picker-native/`](ui-directory-picker-native/README.zh.md) | 驱动宿主 OS 选择器的原生目录选择界面 | — | + +----- + + +## 相关文档 + +先从子系统参考与两份拥有跨包组合决策的 Agent Note 读起,再看服务于本页的宿主半侧。 + +- [客户端模块子系统](../../docs/subsystems/client-modules.zh.md)——web 插件表:`dsh.client` 声明、启动图协议与 bundle 路由。 +- [slot 系统标准](../../.agents/notes/implemented/architecture/2026-07-22-slot-type-chain-implementation.zh.md)——权威 slot 模型:注册、props 份额与 store。 +- [web 客户端架构 Agent Note](../../.agents/notes/implemented/architecture/2026-07-19-gui-web-client-architecture.zh.md)——加载链、对象层与客户端服务。 +- [宿主组地图](../host/README.zh.md)——服务于本浏览器半侧的宿主半侧。 + + +## 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 4745644cc5..75ab67d3de 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/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/connection/README.md -README.md: f0a707cc5f3a962c7852f7323c727d0a39a57b10 -README.zh.md: b529eebf9af93e36b6b92c19964678b7f5a04ea1 +README.md: 72f8ee150c1879f920f189ad3d0221fce449c451 +README.zh.md: 3ef1e22554f568e5d4fe24556fafbc7ce0413209 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index f0a707cc5f..72f8ee150c 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -1,17 +1,51 @@ +--- +description: "Browser-host wire layer for the web GUI: the shared API client, event-stream delivery with reconnect, the /api HTTP bridge, and the browser-trust fence, for users and maintainers composing or debugging the connection." +kind: "package-reference" +--- + # @deepseek-ai/dsh-client-connection English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + current-page loopback state + observable generation-scoped `hostDescription` + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` abstraction, and the loop's sink/config types. Each successful readiness handshake publishes the exact `host.describe` value before `onConnected`; generation loss and explicit stop clear it, so native-capability consumers never retain a disconnected answer. The browser carrier uses HTTP POST for unary and respond operations and opens one downlink-only WebSocket each for `events.mux` and `events.host`; the in-process carrier satisfies the same two-stream abstraction. The Host half owns the single `/api` route and its Fetch bridge; a registered Typert interceptor claims its Remote endpoints before the API Proxy fallback. Loopback hostname classification stays package-internal: the `/api` Host fence and WebSocket upgrades use it directly, while other client plugins consume the derived `ctx.connection.isLoopback` state. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`openDocument`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`; reads and native actions included, since describing returns the exposed configuration, opening acts on the Host desktop, and probing an arbitrary reference reports where a credential comes from — and the agent-preset authoring plane, `agentPreset.read`/`copy`/`openDocument`/`remove`, since a composition names the plugins a session runs, so reading one is reconnaissance, and copy/remove/openDocument manage the roster and drive the host desktop (authoring is copy-only, so none of them accepts composition text or a path); `agentPreset.list` and `agentPreset.select` stay out — the roster carries only ids and trust, and choosing a preset grants nothing `session.create`'s own `agentPreset` did not, over a default that already carries bash) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform carriers and ConnectionController loop are package-internal; apply selects and drives them. The downlink boundary is documented in the [WebSocket downlink carrier Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md). +## Summary -## /api browser-trust fence +Protocol and connection-generation layer. The Client plugin mounts `ctx.connection`, containing the shared API client, current-page loopback state, generation-scoped observable `hostDescription`, a generic RPC carrier, and the registration point for one generation source and the connection loop. A generation publishes `hostDescription` and calls `onConnected` only after its source is ready and `host.describe` succeeds; source completion, failure, withdrawal, or an explicit stop clears that value before `ConnectionController` reconnects with backoff. -The node half guards every entry under `/api` before bridging or upgrading (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for unmarked HTTP requests: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to image and navigation reads, so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; a browser WebSocket handshake carries `Origin` and passes the same comparison. Non-browser clients pass the same fence via loopback, deployment-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. HTTP failures answer plain 403 before any RPC dispatch; upgrade failures reject the handshake before any event stream starts. Non-loopback compositions must trust their serving authorities explicitly: the Web runtime derives LAN IP literals from an all-interfaces server config, while `trustedHosts` in cordis.yml and the CLI's `--trusted-host` flag declare named authorities. `dsh web --host 0.0.0.0` is intentionally unsupported until remote access has an authentication layer. The fence is a reachability policy, not authentication; the Web carrier provides no authentication layer. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +## Table of Contents -## `/api` WebSocket downlinks +- [Use this package](#use-this-package) +- [Browser authentication and request trust](#browser-authentication-and-request-trust) +- [Connection generation](#connection-generation) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -`/api/events.mux` and `/api/events.host` each accept a WebSocket upgrade and send only the corresponding `ServerRequest` text messages to the browser; the client sends no application data over these sockets. If either socket ends, the current connection generation fails and rebuilds both streams; readiness still requires both sockets to be open and the `host.describe` HTTP call to succeed. Host teardown terminates both sockets, aborts their sources, and waits for source cleanup before returning. Ordinary network GETs to these paths return 426 with no SSE fallback; `toFetchHandler`'s SSE codec serves only the isomorphic in-process carrier. +----- + +## Use this package + +The browser uses HTTP POST for API Proxy and generic Remote unary calls. API Gateway owns the `/api/remote.mux` WebSocket and its logical streams; in-process compositions provide equivalent Remote streams through `connection.rpc.open` without opening a WebSocket. The Host half owns the sole `/api` route, Fetch bridge, browser authentication, and Host/Origin checks. Typert Gateway claims its Remote endpoints first, and unclaimed requests fall through to API Proxy. Loopback hostname classification remains package-internal to the browser-facing Client state. + +----- + + +## Browser authentication and request trust + +Every Host RPC method and WebSocket stream requires one browser session; there is no method-specific loopback tier. Each process mints a random launch token. `dsh-web-app` prints and opens the ordinary root URL with `?token=...`; `frontend-static` delegates root and index requests to `ctx.connection.authorizeIndex`, which accepts that token only on `GET /`, writes an authority-bound signed cookie, and redirects to clean `/`. A missing, expired, malformed, or wrong-authority cookie returns 401 before RPC dispatch. Static assets remain public. The HTTP carrier accepts no query token outside the root exchange and no Authorization-header token. + +The cookie signing secret is the owner-scoped `client-connection/browser-session` grant record in `ctx.credentials`. The local provider persists it in `$DSH_HOME/.credentials.yaml`; `BrowserAuth` loads or creates the record during Connection activation and retains the secret in memory, so request authentication is synchronous. Deleting or replacing the record takes effect on the next Connection activation. Cookies carry an absolute issue/expiry interval, defaulting to 30 days through `cookieMaxAgeDays`, and bind the normalized hostname plus port in both their deterministic name and signed payload. They are host-only, `Path=/`, `HttpOnly`, and `SameSite=Strict`; they deliberately omit `Secure` because the shipped server uses loopback HTTP. + +Before authentication, every request still passes `src/api-request-trust.ts`. Its `Host` must be loopback or match a `trustedHosts` entry: exact on `host:port`, any port on port-less entries, both sides WHATWG-normalized. An attached `Origin` must equal that Host and `sec-fetch-site: cross-site` is refused. Malformed configured authorities fail plugin load. These checks defend DNS rebinding and cross-site browser requests; they never establish identity. A failed Host/Origin check returns 403, while a trusted but unauthenticated request returns 401. `dsh web --host 0.0.0.0` remains unsupported. Decision records: [browser request trust](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md) and [browser token authentication](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.md). + + +## Connection generation + +API Gateway Client registers the internal `$events` logical stream as the sole generation source, independently of whether any `$on` listener exists. The Host attaches all incremental listeners in the API Remotes source factory, then sends one `{ type: 'ready' }` item before events. `ConnectionController` waits for that item and `host.describe` in parallel; `onConnected` cannot start baseline reads until both succeed, so baseline acquisition cannot race ahead of incremental observation. + +An ended `$events` stream, a Remote stream error, a non-ready opening item, or a malformed event item invalidates the current generation. The controller immediately withdraws `hostDescription`, publishes `reconnecting`, and rebuilds the `$events` plus `host.describe` handshake after backoff. Gateway mux reconnects the physical WebSocket; Connection generation reopens the logical stream and establishes the next baseline starting point. + + ## Model Experience None, as the wire consumer layer moves already-composed messages between browser and host; nothing here reaches a model request. @@ -22,5 +56,19 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work -- **History resumes an unattached session** — opening history may create the host-side agent and add latency to the first open; there is no persistence-only read path. -- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 160 MiB, sized for the default 100 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. + + +- **The `/api` bridge buffers each request body in memory** — `maxRequestBodyBytes` (default 300 MiB, sized for the default 200 MiB aggregate image limit after base64 expansion plus envelope headroom) is therefore also the per-request resident bound; a streaming body path would be needed to lower it without shrinking the image limits. +- **The browser cookie is not marked `Secure`** — loopback HTTP is the shipped transport, so exposing the same authority over plaintext networking can expose the bearer cookie in transit. +- **There is no logout operation** — clearing the browser cookie ends one browser session; deleting the owner credential record and restarting `dsh` revokes every session. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index b529eebf9a..3ef1e22554 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -1,17 +1,51 @@ +--- +description: "面向用户与维护者的浏览器-宿主线层说明:共享 API 客户端、带重连的事件流投递、/api HTTP 桥与浏览器信任栅栏,用于组合或排查连接。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-client-connection [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 当前页面的 loopback 状态 + 可观察且按 generation 生效的 `hostDescription` + 单消费方流循环启动器);导出表层携带协议约定类型、`AbstractApiClient` 抽象,以及循环的 sink/配置类型。每次就绪握手成功后,都会在 `onConnected` 之前发布完整的 `host.describe` 值;generation 失效或显式 stop 会清空它,因此原生能力消费者不会保留已经断线的判断。浏览器载体以 HTTP POST 发送 unary/respond,并为 `events.mux` 与 `events.host` 各开一条只下行的 WebSocket;进程内载体满足同一双流抽象。Host half 持有唯一 `/api` route 及其 Fetch bridge;已注册的 Typert interceptor 会先认领自己的 Remote endpoint,未认领请求再回退 API Proxy。Loopback hostname 判定逻辑留在包内部:`/api` Host fence 与 WebSocket upgrade 会直接使用它,其他客户端插件则消费派生的 `ctx.connection.isLoopback` 状态。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`openDocument`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`;读取与原生操作也在内,因为 describe 会返回已暴露的配置、打开操作会作用于 Host 桌面,而探测任意引用会报出某条凭据来自何处——以及 agent(智能体) preset 的创作面 `agentPreset.read`/`copy`/`openDocument`/`remove`,因为组装指明了一个会话所运行的插件,读取它是侦察,而 copy/remove/openDocument 管理名单并驱动宿主桌面(创作只有复制一种写入,因此这些方法都不接收组装文本或路径);`agentPreset.list` 与 `agentPreset.select` 不在其中——名单只携带 id 与信任级别,而选择一个 preset 并不比 `session.create` 自带的 `agentPreset` 多给任何能力,何况默认 preset 本就带着 bash)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台载体与 ConnectionController 循环属于包内部;apply 负责选择并驱动它们。下行边界见 [WebSocket 下行载体 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-04-websocket-downlink-carrier.md)。 +## 概述 -## /api 浏览器信任栅栏 +协议与连接世代层:Client 插件挂载 `ctx.connection`,包含共享 API 客户端、当前页面的 loopback 状态、按 generation 生效的可观察 `hostDescription`、通用 RPC carrier,以及单一 generation source 与连接循环的注册面。每个 generation 只在 source 已就绪且 `host.describe` 成功后发布 `hostDescription` 并调用 `onConnected`;source 结束、失败、被撤回或显式 stop 都会清空该值,再由 `ConnectionController` 退避重连。 -node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的 HTTP 请求开捷径:明文 HTTP 下浏览器的图片与导航读取既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;WebSocket 浏览器握手会带 `Origin` 并通过同一道比较。非浏览器客户端经由回环地址、部署推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,如附带 `Origin`,则它必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载明确报错:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。HTTP 失败在任何 RPC 分发之前以纯 403 应答,upgrade 失败在启动任何事件流前拒绝握手。非回环组合必须显式信任其服务权威:Web 运行时从全接口服务器配置推导 LAN IP 字面量,cordis.yml 中的 `trustedHosts` 与 CLI(命令行界面)的 `--trusted-host` flag 则声明具名权威。`dsh web --host 0.0.0.0` 在远程访问具备认证层之前有意不受支持。这道栅栏是可达性策略,而不是认证;Web 载体不提供认证层。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +## 目录 -## `/api` WebSocket 下行 +- [使用本包](#use-this-package) +- [浏览器认证与请求信任](#browser-authentication-and-request-trust) +- [Connection generation](#connection-generation) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -`/api/events.mux` 与 `/api/events.host` 各接受一条 WebSocket upgrade,并只向浏览器发送对应的 `ServerRequest` 文本消息;客户端不会在这些 socket 上发送业务数据。任一 socket 结束都会使当前 connection generation 失败并重建两条流,连接就绪仍要求两条 socket 均已打开且 `host.describe` HTTP 调用成功。Host teardown 会终止两条 socket、中止各自的 source,并等待 source 清理完成后再返回。普通网络 GET 这些路径会返回 426,不保留 SSE(Server-Sent Events)回退;`toFetchHandler` 的 SSE 编解码只服务进程内同构载体。 +----- + +## 使用本包 + +浏览器通过 HTTP POST 执行 API Proxy 一元调用与通用 Remote 一元调用;API Gateway 自己拥有 `/api/remote.mux` WebSocket 及其逻辑流。进程内组合通过 `connection.rpc.open` 提供等价的 Remote 流,不打开 WebSocket。Host half 拥有唯一 `/api` route、Fetch bridge、浏览器认证与 Host/Origin 校验;Typert Gateway 先认领自己的 Remote endpoint,未认领的请求再回退 API Proxy。Loopback hostname 判定只供浏览器侧当前页面状态使用,留在包内。 + +----- + + +## 浏览器认证与请求信任 + +每个 Host RPC 方法和 WebSocket stream 都要求同一个浏览器会话,不存在按方法区分的 loopback 层。每个进程生成一个随机启动令牌。`dsh-web-app` 打印并打开带 `?token=...` 的普通根 URL;`frontend-static` 把根路径和 index 请求交给 `ctx.connection.authorizeIndex`,后者只在 `GET /` 接受该令牌,写入绑定 authority 的签名 cookie,再重定向到干净的 `/`。缺失、过期、畸形或 authority 不匹配的 cookie 会在 RPC 分发前得到 401。静态资源保持公开。HTTP 载体不在根路径交换之外接受 query token,也不接受 Authorization header token。 + +cookie 签名密钥是 `ctx.credentials` 中由 `client-connection/browser-session` 拥有的 grant 记录。本地提供方把它持久化到 `$DSH_HOME/.credentials.yaml`;`BrowserAuth` 在 Connection 激活期间加载或创建该记录,并把密钥留在内存中,因此请求认证同步执行。删除或替换该记录会在下一次 Connection 激活时生效。cookie 携带绝对签发与过期区间,`cookieMaxAgeDays` 默认设为 30 天,并在确定性名称与签名 payload 中同时绑定规范化 hostname 和 port。它是 host-only、`Path=/`、`HttpOnly`、`SameSite=Strict`;随附服务器使用 loopback HTTP,因此刻意不设置 `Secure`。 + +认证之前,每个请求仍经过 `src/api-request-trust.ts`。其 `Host` 必须是 loopback,或与 `trustedHosts` 条目匹配:带端口的 `host:port` 精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化。若附带 `Origin`,它必须等于该 Host;`sec-fetch-site: cross-site` 一律拒绝。畸形配置 authority 会让插件加载失败。这些检查防御 DNS rebinding 与跨站浏览器请求,绝不建立身份。Host/Origin 校验失败返回 403;Host 可信但未认证的请求返回 401。`dsh web --host 0.0.0.0` 仍不受支持。决策记录:[浏览器请求信任](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.zh.md)与[浏览器令牌认证](../../../.agents/notes/implemented/architecture/2026-08-24-browser-token-authentication.zh.md)。 + + +## Connection generation + +API Gateway Client 把内部 `$events` logical stream 注册为唯一 generation source,与有无 `$on` 订阅无关。Host 在 API Remotes source factory 同步挂好所有增量 listener 后,先发送唯一 `{ type: 'ready' }` 项,再发送事件。`ConnectionController` 并行等待该 ready 与 `host.describe`;只有两者都成功才允许 `onConnected` 启动 baseline 读取,因此 baseline 不会跑在增量 listener 前面。 + +`$events` 结束、返回 Remote stream error、收到非 ready 首项或畸形事件项,都会使当前 generation 失效。Controller 立即撤回 `hostDescription`、发布 `reconnecting`,并在退避后重建 `$events` 与 `host.describe` 握手。Gateway mux 自己负责重建底层 WebSocket;Connection 世代负责重建 logical stream 与 baseline 起点。 + + ## 模型体验 无。协议消费层只在浏览器与主机之间搬运已经组合好的消息;这里没有任何内容进入模型请求。 @@ -22,5 +56,19 @@ node 半侧在桥接或 upgrade 前守卫 `/api` 下的每个入口(`src/api-r ## 已知限制与暂缓事项 -- **History 会恢复未附加的会话**:打开 history 可能创建宿主侧 agent,并增加首次打开的延迟;没有仅从持久化读取的路径。 -- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 160 MiB,按默认 100 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 + + +- **`/api` 桥把每个请求体整体缓冲在内存里**:`maxRequestBodyBytes`(默认 300 MiB,按默认 200 MiB 图片总量上限经 base64 膨胀加信封余量得出)因此同时是单请求的驻留内存上界;要降低它而不缩小图片限额,需要流式请求体路径。 +- **浏览器 cookie 不带 `Secure`**:随附载体是 loopback HTTP;若部署经明文网络暴露同一 authority,bearer cookie 可能在传输中泄露。 +- **没有 logout 操作**:清除浏览器 cookie 会结束单个浏览器会话;删除 owner 凭据记录并重启 `dsh` 会撤销全部会话。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 49921b4df9..5c7e53bb70 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-connection", - "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", - "version": "0.1.0-rc.7", + "description": "Wire consumer layer: HTTP client, generation lifecycle, and fixture API", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -38,8 +38,7 @@ }, "license": "MIT", "dependencies": { - "@deepseek-ai/schemastery": "workspace:^", - "ws": "^8.21.0" + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", @@ -52,22 +51,23 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tool-todo": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@types/ws": "^8.18.1", "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/dsh-tools": "workspace:^" + "@deepseek-ai/dsh-tool-todo": "workspace:^" } } diff --git a/packages/client/connection/src/api-path.ts b/packages/client/connection/src/api-path.ts index f34aa231d4..ee54f61b03 100644 --- a/packages/client/connection/src/api-path.ts +++ b/packages/client/connection/src/api-path.ts @@ -1,14 +1,7 @@ /** * The /api URL prefix — single source for both halves of the web transport. - * The node half registers this prefix on the web server; both halves share the - * event paths below for the browser WebSocket downlinks. + * The node half registers this prefix on the web server. */ /** Route prefix owning every api request (`/api` and `/api/`). */ export const API_PATH = '/api' - -/** Browser mux-frame WebSocket pathname. */ -export const MUX_EVENTS_PATH = `${API_PATH}/events.mux` - -/** Browser host-frame WebSocket pathname. */ -export const HOST_EVENTS_PATH = `${API_PATH}/events.host` diff --git a/packages/client/connection/src/api-request-trust.ts b/packages/client/connection/src/api-request-trust.ts index ea8914ccc6..1065dfe876 100644 --- a/packages/client/connection/src/api-request-trust.ts +++ b/packages/client/connection/src/api-request-trust.ts @@ -13,15 +13,10 @@ * belongs to the webserver config, and this fence is not an auth layer. */ -import type { IncomingHttpHeaders } from 'node:http' import { isLoopbackHostname } from './loopback-hostname.ts' +import type { ConnectionTrustRequest } from './rpc.ts' -/** The request facts the fence reads from either HTTP representation. */ -interface ApiTrustRequest { - headers: IncomingHttpHeaders | Headers -} - -function header(headers: IncomingHttpHeaders | Headers, name: string): string | undefined { +function header(headers: ConnectionTrustRequest['headers'], name: string): string | undefined { if (headers instanceof Headers) return headers.get(name) ?? undefined const value = headers[name] return typeof value === 'string' ? value : undefined @@ -93,7 +88,7 @@ function isTrustedAuthority(hostUrl: URL, trustedHosts: readonly string[]): bool * @param trustedHosts - non-loopback authorities this deployment serves: exact `host:port`, or port-less `host` matching any port. * @returns true when the Host is ours (loopback or trusted) and any attached browser markers are same-origin. */ -export function isTrustedApiRequest(request: ApiTrustRequest, trustedHosts: readonly string[]): boolean { +export function isTrustedApiRequest(request: ConnectionTrustRequest, trustedHosts: readonly string[]): boolean { // Host fence (DNS-rebinding defense), applied to every request: the browser // fills Host from the URL it believes it is talking to, so a rebound page // carries the attacker's domain here even though the socket lands on this diff --git a/packages/client/connection/src/browser-auth.ts b/packages/client/connection/src/browser-auth.ts new file mode 100644 index 0000000000..10353d9a06 --- /dev/null +++ b/packages/client/connection/src/browser-auth.ts @@ -0,0 +1,313 @@ +/** Browser-session authentication for the Host Connection carrier. */ + +import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto' +import { credentialKey } from '@deepseek-ai/dsh-credentials' +import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' +import type { + ConnectionIndexRequest, + ConnectionIndexResponse, + ConnectionTrustRequest, +} from './rpc.ts' + +const AUTH_RECORD_KEY = credentialKey('client-connection', 'browser-session') +const DAY_MILLISECONDS = 24 * 60 * 60 * 1000 +const SECRET_BYTES = 32 +const TOKEN_QUERY = 'token' +const COOKIE_PREFIX = 'dsh-auth-' +const COOKIE_PAYLOAD_VERSION = 1 +const STORED_SECRET_VERSION = 1 +const BASE64URL_PATTERN = /^[A-Za-z0-9_-]*$/ +const PROCESS_LAUNCH_TOKENS = new WeakMap() + +interface StoredSecretPayload { + readonly version: typeof STORED_SECRET_VERSION + readonly secret: string +} + +interface BrowserCookiePayload { + readonly version: typeof COOKIE_PAYLOAD_VERSION + readonly authority: string + readonly issuedAt: number + readonly expiresAt: number +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function encodeBase64Url(value: Uint8Array): string { + return Buffer.from(value).toString('base64') + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') +} + +function decodeBase64Url(value: string): Buffer | undefined { + if (!BASE64URL_PATTERN.test(value) || value.length % 4 === 1) return undefined + const padding = '='.repeat((4 - value.length % 4) % 4) + const decoded = Buffer.from(value.replaceAll('-', '+').replaceAll('_', '/') + padding, 'base64') + return encodeBase64Url(decoded) === value ? decoded : undefined +} + +function processLaunchToken(owner: object): string { + const existing = PROCESS_LAUNCH_TOKENS.get(owner) + if (existing !== undefined) return existing + const created = encodeBase64Url(randomBytes(SECRET_BYTES)) + PROCESS_LAUNCH_TOKENS.set(owner, created) + return created +} + +function header( + headers: ConnectionTrustRequest['headers'], + name: string, +): string | undefined { + if (headers instanceof Headers) return headers.get(name) ?? undefined + const value = headers[name] + return typeof value === 'string' ? value : undefined +} + +/** Canonical request authority used as the cookie name and signed audience. */ +function requestAuthority(headers: ConnectionTrustRequest['headers']): string | undefined { + const host = header(headers, 'host') + if (host === undefined) return undefined + try { + return new URL(`http://${host}`).host + } catch { + return undefined + } +} + +function canonicalSecret(value: unknown): Buffer | undefined { + if (typeof value !== 'string') return undefined + const decoded = decodeBase64Url(value) + if (decoded === undefined || decoded.byteLength !== SECRET_BYTES) return undefined + return decoded +} + +function storedSecret(record: CredentialRecord | undefined): Buffer | undefined { + if (record === undefined) return undefined + if (record.kind !== 'grant' || !isRecord(record.payload) + || record.payload.version !== STORED_SECRET_VERSION) { + throw new Error('client-connection: browser-session credential record has an unsupported format') + } + const secret = canonicalSecret(record.payload.secret) + if (secret === undefined) { + throw new Error('client-connection: browser-session credential record has an invalid secret') + } + return secret +} + +function tokenMatches(actual: string, expected: string): boolean { + const actualBytes = Buffer.from(actual, 'utf8') + const expectedBytes = Buffer.from(expected, 'utf8') + return actualBytes.byteLength === expectedBytes.byteLength && timingSafeEqual(actualBytes, expectedBytes) +} + +function cookieName(authority: string): string { + return COOKIE_PREFIX + encodeBase64Url(createHash('sha256').update(authority).digest()) +} + +/** Read the exact generated cookie without implementing general Cookie decoding. */ +function cookieValue(headerValue: string, name: string): string | undefined { + for (const segment of headerValue.split(';')) { + const at = segment.indexOf('=') + if (at === -1 || segment.slice(0, at).trim() !== name) continue + return segment.slice(at + 1).trim() + } + return undefined +} + +/** Serialize the fixed browser-session attributes; generated names and values are cookie-safe base64url. */ +function sessionCookie(name: string, value: string, expiresAt: number, maxAgeSeconds: number): string { + return `${name}=${value}; Max-Age=${String(maxAgeSeconds)}; Path=/; Expires=${new Date(expiresAt).toUTCString()}; HttpOnly; SameSite=Strict` +} + +function signature(secret: Buffer, body: string): Buffer { + return createHmac('sha256', secret).update(body).digest() +} + +function encodeCookie(payload: BrowserCookiePayload, secret: Buffer): string { + const body = encodeBase64Url(Buffer.from(JSON.stringify(payload), 'utf8')) + return `v1.${body}.${encodeBase64Url(signature(secret, body))}` +} + +function decodeCookie(value: string, secret: Buffer): BrowserCookiePayload | undefined { + const parts = value.split('.') + const [version, body, encodedSignature] = parts + if (parts.length !== 3 || version !== 'v1' || body === undefined || encodedSignature === undefined) { + return undefined + } + const actualSignature = decodeBase64Url(encodedSignature) + if (actualSignature === undefined) return undefined + const expectedSignature = signature(secret, body) + if (actualSignature.byteLength !== expectedSignature.byteLength + || !timingSafeEqual(actualSignature, expectedSignature)) return undefined + let decoded: unknown + try { + const bodyBytes = decodeBase64Url(body) + if (bodyBytes === undefined) return undefined + decoded = JSON.parse(bodyBytes.toString('utf8')) + } catch { + return undefined + } + if (!isRecord(decoded) + || decoded.version !== COOKIE_PAYLOAD_VERSION + || typeof decoded.authority !== 'string' + || !Number.isSafeInteger(decoded.issuedAt) + || !Number.isSafeInteger(decoded.expiresAt)) return undefined + return decoded as unknown as BrowserCookiePayload +} + +async function initializeSecret(credentials: CredentialProvider): Promise { + const generated: StoredSecretPayload = { + version: STORED_SECRET_VERSION, + secret: encodeBase64Url(randomBytes(SECRET_BYTES)), + } + const record = await credentials.modifyRecord(AUTH_RECORD_KEY, (current) => { + if (current !== undefined) { + storedSecret(current) + return Promise.resolve(undefined) + } + return Promise.resolve({ kind: 'grant', payload: generated }) + }) + const secret = storedSecret(record) + if (secret === undefined) { + throw new Error('client-connection: browser-session credential record was not created') + } + return secret +} + +/** + * Process launch-token exchange and persistent signed-cookie verification. + * Connection loads the credential provider's signing secret during activation + * and retains it for synchronous request authentication. + */ +export class BrowserAuth { + private readonly launchToken: string + private readonly maxAgeMilliseconds: number + + private constructor( + processOwner: object, + private readonly secret: Buffer, + maxAgeDays: number, + ) { + this.launchToken = processLaunchToken(processOwner) + this.maxAgeMilliseconds = maxAgeDays * DAY_MILLISECONDS + if (!Number.isSafeInteger(this.maxAgeMilliseconds) + || !Number.isSafeInteger(Date.now() + this.maxAgeMilliseconds)) { + throw new Error('client-connection: cookieMaxAgeDays exceeds the safe timestamp range') + } + } + + /** + * Initialize browser authentication and create its durable signing secret + * when this Harness home has none. + * @param processOwner - root application context retaining one token across Connection reloads. + * @param credentials - persistent credential provider for the Web profile. + * @param maxAgeDays - positive absolute browser-cookie lifetime in days. + * @returns initialized authentication owner with the process owner's launch token. + */ + static async create( + processOwner: object, + credentials: CredentialProvider, + maxAgeDays: number, + ): Promise { + return new BrowserAuth(processOwner, await initializeSecret(credentials), maxAgeDays) + } + + /** + * Add this process's launch token to the ordinary application root URL. + * @param baseUrl - canonical browser origin without credentials. + * @returns root URL carrying the process token as its sole authentication input. + */ + authenticatedUrl(baseUrl: string): string { + const url = new URL(baseUrl) + url.pathname = '/' + url.search = '' + url.hash = '' + url.searchParams.set(TOKEN_QUERY, this.launchToken) + return url.href + } + + /** + * Authenticate an index request. A valid root query token mints the cookie + * and redirects to clean `/`; a valid cookie lets the caller serve the + * index; every other request receives the same minimal 401 response. + * @param req - incoming root or configured-index request. + * @param res - response owned when this method returns false. + * @returns true only when the caller may serve index.html. + */ + authorizeIndex(req: ConnectionIndexRequest, res: ConnectionIndexResponse): boolean { + /* v8 ignore next -- node:http always supplies url on server requests. */ + const url = new URL(req.url ?? '/', 'http://dsh.invalid') + const tokens = url.searchParams.getAll(TOKEN_QUERY) + if (tokens.length > 0) { + const authority = requestAuthority(req.headers) + if (req.method === 'GET' && url.pathname === '/' && tokens.length === 1 + && authority !== undefined && tokenMatches(tokens.join(''), this.launchToken)) { + const issuedAt = Date.now() + const expiresAt = issuedAt + this.maxAgeMilliseconds + const value = encodeCookie({ + version: COOKIE_PAYLOAD_VERSION, + authority, + issuedAt, + expiresAt, + }, this.secret) + res.writeHead(303, { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + 'set-cookie': sessionCookie( + cookieName(authority), value, expiresAt, Math.floor(this.maxAgeMilliseconds / 1000), + ), + }) + res.end() + return false + } + if (req.method === 'GET' && url.pathname === '/' && this.isAuthenticated(req)) { + res.writeHead(303, { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + }) + res.end() + return false + } + this.writeUnauthorized(req, res) + return false + } + if (this.isAuthenticated(req)) return true + this.writeUnauthorized(req, res) + return false + } + + /** + * Verify the authority-bound browser cookie on a Host request. + * @param request - request headers carrying Host and Cookie. + * @returns true only for an unexpired cookie signed by this activation's loaded secret. + */ + isAuthenticated(request: ConnectionTrustRequest): boolean { + const authority = requestAuthority(request.headers) + const rawCookie = header(request.headers, 'cookie') + if (authority === undefined || rawCookie === undefined) return false + const value = cookieValue(rawCookie, cookieName(authority)) + if (value === undefined) return false + const payload = decodeCookie(value, this.secret) + if (payload === undefined || payload.authority !== authority) return false + const now = Date.now() + return payload.issuedAt <= now + && payload.expiresAt > now + && payload.expiresAt > payload.issuedAt + && payload.expiresAt - payload.issuedAt <= this.maxAgeMilliseconds + } + + private writeUnauthorized(req: ConnectionIndexRequest, res: ConnectionIndexResponse): void { + res.writeHead(401, { + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + }) + res.end(req.method === 'HEAD' + ? undefined + : 'dsh web authentication required; reopen the URL printed by dsh web.\n') + } +} diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index 1b7627b293..3a3c78dfa5 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -1,35 +1,29 @@ -// Central contract re-export point: every contract import inside -// web-runtime goes through this single file. +// Central contract re-export point: every legacy API contract import inside +// the Connection package goes through this browser-safe file. // Types and runtime protocol helpers/bounds come from the apiproxy api/ layer // (zero Node deps, browser-safe); AbstractApiClient is the client boundary. // NEVER import the package root: it drags bootHost/cordis into the browser bundle. // The ./api and ./client subpath exports are the browser-safe channels. export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ResponseValue, WorkspaceApi, WorkspaceId, WorkspaceView, + ResponseValue, SkillsApi, SkillEntry, - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, - GoalsApi, GoalRef, + ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + ModelReasoningEffort, ModelSelection, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, - SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, - JobView, } from '@deepseek-ai/dsh-host-apiproxy/api' -export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, + ClientRequest, ServerResponse, RpcMessage, } from '@deepseek-ai/dsh-host-apiproxy/api' // transportError lives in the apiproxy api layer (beside RpcResult, its // subject); re-exported here so connection consumers keep one contract // entry point. export { RpcId, - SESSION_SEARCH_RESULT_LIMIT, transportError, } from '@deepseek-ai/dsh-host-apiproxy/api' export { AbstractApiClient } from '@deepseek-ai/dsh-host-apiproxy/client' diff --git a/packages/client/connection/src/client/connection.ts b/packages/client/connection/src/client/connection.ts index 8b41053424..cad2de394d 100644 --- a/packages/client/connection/src/client/connection.ts +++ b/packages/client/connection/src/client/connection.ts @@ -1,4 +1,4 @@ -import type { HostDescription, IApiClient, HostFrame, MuxFrame, RpcRequest } from './api.ts' +import type { HostDescription, IApiClient } from './api.ts' /** Reconnect/backoff tunables (deployment-varying — no hardcoded tunables; these become the * future `ctx.connection` plugin's Config). All fields optional; defaults below. */ @@ -9,18 +9,15 @@ export interface ConnectionConfig { backoffFactor?: number /** Upper bound for the backoff cap in ms. */ backoffMaxMs?: number - /** Cap on waiting for both streams' onOpen before onConnected, in ms. The strict handshake - * waits for mux+host stream establishment plus describe; a carrier that never - * fires onOpen (misbehaving proxy) must not wedge the connection forever — on timeout the - * generation proceeds as connected and the live-gap repair path covers stragglers. */ - streamOpenTimeoutMs?: number + /** Maximum wait for the registered generation source's ready signal. */ + generationReadyTimeoutMs?: number } const CONNECTION_DEFAULTS: Required = { backoffBaseMs: 500, backoffFactor: 2, backoffMaxMs: 10_000, - streamOpenTimeoutMs: 3_000, + generationReadyTimeoutMs: 3_000, } function sleep(ms: number, signal: AbortSignal): Promise { @@ -39,12 +36,9 @@ function sleep(ms: number, signal: AbortSignal): Promise { * 'reconnecting' the moment the generation fails (covers the whole backoff+retry span). */ export type ConnectionState = 'connected' | 'reconnecting' -/** Frame sink callbacks: the Controller owns the physical streams; business dispatch belongs to - * SessionManager. */ +/** Connection-generation callbacks owned by API Gateway. */ export interface ConnectionSinks { - onMuxEnvelope?: (envelope: RpcRequest) => void - onHostEnvelope?: (envelope: RpcRequest) => void - /** After each connection generation is established (both streams open + describe succeeded), first connect included. */ + /** After the generation source is ready and host.describe succeeds, first connect included. */ onConnected?: (description: HostDescription) => void /** Coarse state transitions (deduplicated: fires only on change). The initial pre-connect * span reports nothing — the UI treats "no state yet" as connecting, not as an outage. */ @@ -52,11 +46,22 @@ export interface ConnectionSinks { } /** - * Opens both streams and keeps iterating (pull mode: nothing reads the socket and the tap - * never fires unless someone for-awaits), reconnecting with exponential backoff on loss. + * One long-lived source defining a Connection generation. The source must + * attach its incremental listeners before calling `ready`, then remain pending + * until the generation is lost or `signal` aborts. + * @param signal - cancellation for the current generation. + * @param ready - one-shot report that incremental delivery is attached. + * @returns a promise settling only when this generation ends or fails. + */ +export type ConnectionGenerationSource = ( + signal: AbortSignal, + ready: () => void, +) => Promise + +/** + * Opens the registered generation source, reconnecting with exponential backoff on loss. * State (generation/attempt) is instance-private, never in the store. - * The pump body feeds each frame to a sink (sink exceptions must - * not kill the pump — a broken business layer must not drag down the connection layer). + * Sink exceptions do not kill the generation loop. */ export class ConnectionController { private generation = 0 @@ -68,6 +73,7 @@ export class ConnectionController { constructor( private readonly api: IApiClient, + private readonly source: ConnectionGenerationSource, private readonly sinks: ConnectionSinks = {}, config: ConnectionConfig = {}, ) { @@ -81,7 +87,7 @@ export class ConnectionController { void this.loop() } - /** Stop the loop and abort the current generation's streams. */ + /** Stop the loop and abort the current generation source. */ stop(): void { this.running = false this.current?.abort() @@ -110,37 +116,58 @@ export class ConnectionController { const ac = new AbortController() this.current = ac - /* v8 ignore next -- initializer placeholder: the Promise executor - * below runs synchronously and replaces it before anyone can call it. */ - let muxOpened = (): void => {} - /* v8 ignore next -- same placeholder pattern as muxOpened. */ - let hostOpened = (): void => {} - const streamsOpen = Promise.all([ - new Promise((resolve) => { muxOpened = resolve }), - new Promise((resolve) => { hostOpened = resolve }), - ]) + let sourceReady = false + let resolveReady!: () => void + let rejectReady!: (error: Error) => void + let rejectSourceLost!: (error: Error) => void + const ready = new Promise((resolve, reject) => { + resolveReady = resolve + rejectReady = reject + }) + const sourceLost = new Promise((_resolve, reject) => { + rejectSourceLost = reject + }) + const reportReady = (): void => { + sourceReady = true + resolveReady() + } const failed = new Promise((resolve) => { const settle = (): void => { if (gen === this.generation && !ac.signal.aborted) ac.abort() resolve() } - void this.pumpStream(this.api.events.mux({}, ac.signal, muxOpened), this.sinks.onMuxEnvelope, settle) - void this.pumpStream(this.api.events.host({}, ac.signal, hostOpened), this.sinks.onHostEnvelope, settle) + void Promise.resolve() + .then(() => this.source(ac.signal, reportReady)) + .then( + () => { + const error = new Error('connection generation ended') + if (!sourceReady) rejectReady(error) + rejectSourceLost(error) + settle() + }, + (error: unknown) => { + const failure = error instanceof Error + ? error + : new Error('connection generation failed', { cause: error }) + if (!sourceReady) rejectReady(failure) + rejectSourceLost(failure) + settle() + }, + ) }) try { - // Strict readiness handshake: describe proves unary reachability, onOpen - // proves each physical stream is established before any frame — - // only then may onConnected fire, so the resync it triggers cannot outrun the - // subscribed baseline. The timeout guards against a carrier that never fires onOpen - // (see ConnectionConfig.streamOpenTimeoutMs). - const timeout = new AbortController() - const [description] = await Promise.all([ - this.api.host.describe({}), - Promise.race([streamsOpen, sleep(this.config.streamOpenTimeoutMs, timeout.signal)]), + // The source reports ready only after its incremental listeners exist; + // describe may complete in parallel, but consumers see neither result + // until both sides of the baseline-plus-increment handshake are ready. + const [description] = await Promise.race([ + Promise.all([ + this.api.host.describe({}, ac.signal), + waitForReady(ready, this.config.generationReadyTimeoutMs, ac.signal), + ]), + sourceLost, ]) - timeout.abort() const descriptionResult = description.result if (!descriptionResult.ok) { throw new Error(`host.describe failed: ${descriptionResult.error.code}: ${descriptionResult.error.message}`) @@ -162,7 +189,7 @@ export class ConnectionController { if (!this.isRunning()) return this.emitState('reconnecting') this.attempt += 1 - console.warn(`[web-runtime] connection lost, retry #${this.attempt}`) + console.warn(`[connection] connection lost, retry #${this.attempt}`) const idle = new AbortController() await sleep(this.backoffDelay(this.attempt), idle.signal) } @@ -175,28 +202,40 @@ export class ConnectionController { this.callSink(() => this.sinks.onStateChange?.(state)) } - private async pumpStream( - stream: AsyncIterable>, - sink: ((envelope: RpcRequest) => void) | undefined, - onEnd: () => void, - ): Promise { - try { - for await (const envelope of stream) { - if (envelope.payload.type === 'stream/error') break - if (sink !== undefined) this.callSink(() => { sink(envelope) }) - } - } catch { - // Stream loss: converge on onEnd, which triggers the shared reconnect. - } - onEnd() - } - /** Sink exception isolation: a business-layer throw is logged only, never affecting pump or reconnect semantics. */ private callSink(fn: () => void): void { try { fn() } catch (error) { - console.error('[web-runtime] connection sink threw:', error) + console.error('[connection] connection sink threw:', error) } } } + +/** Await source readiness without letting a stalled carrier wedge startup forever. */ +function waitForReady(ready: Promise, timeoutMs: number, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + let settled = false + const timeout = setTimeout(() => { + finish(new Error(`connection generation was not ready within ${String(timeoutMs)}ms`)) + }, timeoutMs) + const aborted = (): void => { + finish(new Error('connection generation aborted', { cause: signal.reason })) + } + const finish = (error?: Error): void => { + if (settled) return + settled = true + clearTimeout(timeout) + signal.removeEventListener('abort', aborted) + if (error === undefined) resolve() + else reject(error) + } + signal.addEventListener('abort', aborted, { once: true }) + void ready.then( + () => { finish() }, + (error: unknown) => { + finish(error as Error) + }, + ) + }) +} diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 214331a0df..f3a20282ed 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -1,45 +1,324 @@ -// FixtureApi: standalone UI development without a server. Real contract shape: unary takes -// RpcRequest

and returns RpcResponse (echoing the rpcId); streams yield RpcRequest -// (the fixture IS the fake server, so it mints frame rpcIds); root respond takes ClientResponse -// and returns RpcReceipt. fx-alpha carries a hand-built history script (74 turns, pageable); -// prompt triggers a chunked streaming replay; cancel stops the replay; resident pending -// approval/question requests exercise replay and composer takeover with stable rpcIds. +// Standalone browser fixture for UI development without a server. import { createAssistantMessage, createToolResultMessage, createUserMessage, - isTokenDelta, } from '@deepseek-ai/dsh-llm/message' -import { CallId } from '@deepseek-ai/dsh-llm/brand' +import { ToolCallId, type MessageId } from '@deepseek-ai/dsh-llm/brand' import type { AssistantMessage, ContentBlock, MessageSource, + StreamChunk, TokenUsage, ToolResultMessage, UserMessage, } from '@deepseek-ai/dsh-llm' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { + JsonValue, SessionEvent, + SessionHeader, SessionId, - TodoItem, } from '@deepseek-ai/dsh-session/types' +import { isChunkRow, packChunkRuns } from '@deepseek-ai/dsh-session/chunk-rows' +import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import type { TodoItem } from '@deepseek-ai/dsh-tool-todo/client' // Type-only: the brand constructor is host-side; the fixture casts at its // wire-fabrication boundary (the schema layer's one-cast-point posture). import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CommandDescriptor, CommandExecution, CommandResult } from '@deepseek-ai/dsh-commands/types' import { deriveEventMessage, foldSurface } from '@deepseek-ai/dsh-session/surface' import type { - ApiProxy, ClientRequest, ClientResponse, HistoryEntry, HostFrame, MuxFrame, RpcReceipt, - ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerRequest, ServerResponse, SessionSummary, - ToolCallView, ToolEventView, ToolResultView, WorkspaceId, WorkspaceView, + ApiProxy, ClientRequest, + ModelProviderGroup, ModelSelection, RpcRequest, RpcResponse, RpcResult, ServerResponse, } from './api.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '@deepseek-ai/dsh-host-apiproxy/api' -import { AbstractApiClient, RpcId, SESSION_SEARCH_RESULT_LIMIT } from './api.ts' +import { AbstractApiClient, RpcId } from './api.ts' import { randomUuid } from './random-uuid.ts' -import type { ClientConnectionRpc } from '../rpc.ts' +import type { + ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, +} from '../rpc.ts' + +const FIXTURE_SESSION_SEARCH_RESULT_LIMIT = 20 + +/* jscpd:ignore-start -- The standalone fixture mirrors host timing without importing a target implementation. */ +function isFixtureTokenDelta(chunk: StreamChunk): boolean { + switch (chunk.type) { + case 'text-delta': + case 'reasoning-delta': + return chunk.text !== '' + case 'tool-call-delta': + return chunk.argumentsDelta !== '' || chunk.name !== undefined + default: + return false + } +} +/* jscpd:ignore-end */ + +interface FixtureSessionSummary { + readonly sessionId: SessionId + updatedAt: number + running: boolean + blank: boolean + readonly parentSessionId?: SessionId + readonly origin?: 'subagent' + readonly cwd?: string + readonly agentPreset?: string + readonly projections?: FixtureProjectionsBlock +} + +interface FixtureProjectionsBlock { + readonly asOfSeq: number + readonly values: Readonly> +} + +interface FixtureHistoryEntry { + readonly type: 'event' + readonly event: SessionEvent +} + +type FixtureChunkRowEvent = { + [Kind in ChunkRow['type']]: { + readonly type: `chunkrow/${Kind}` + readonly seq: number + readonly time: number + readonly data: Extract['data'] + } +}[ChunkRow['type']] + +interface FixtureHistoryChunkRun { + readonly type: 'chunks' + readonly event: FixtureChunkRowEvent +} + +type FixtureHistoryRecord = FixtureHistoryEntry | FixtureHistoryChunkRun + +type FixtureSessionAddress = + | { readonly kind: 'session'; readonly sessionId: SessionId } + | { + readonly kind: 'subagent' + readonly parentSessionId: SessionId + readonly childSessionId: SessionId + readonly mode: 'one-shot' | 'continuable' + } + +interface FixtureFollowRequest { + readonly address: FixtureSessionAddress + readonly maxMessages?: number +} + +interface FixturePageRequest { + readonly address: FixtureSessionAddress + readonly throughSeq: number + readonly beforeSeq?: number + readonly maxMessages?: number +} + +type FixtureFollowFrame = + | { + readonly type: 'snapshot' + readonly header: SessionHeader + readonly cursor: number + readonly records: readonly FixtureHistoryRecord[] + readonly hasMore: boolean + readonly projections: FixtureProjectionsBlock + } + | FixtureHistoryEntry + +type FixtureFollowEventFrame = Extract + +interface FixtureRemoteEventNotificationFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +interface FixtureRemoteEventInvocationFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: string + readonly agentId: SessionId + readonly request: Readonly> +} + +interface FixtureRemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: string +} + +type FixtureRemoteEventFrame = + | FixtureRemoteEventNotificationFrame + | FixtureRemoteEventInvocationFrame + | FixtureRemoteEventCancellationFrame + +interface FixtureRemoteEventResult { + readonly clientId: string + readonly eventId: string + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + } +} + +interface FixtureRemoteEventReadyFrame { + readonly type: 'ready' + readonly clientId: string +} + +interface FixtureProjectionFrame { + readonly type: 'projection' + readonly sessionId: SessionId + readonly key: string + readonly value: unknown + readonly seq: number +} + +interface FixtureQuestionItem { + readonly id: string + readonly header?: string + readonly question: string + readonly detail?: string + readonly multiSelect?: boolean + readonly options?: readonly { readonly label: string; readonly description?: string }[] +} + +type FixtureControlFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly never[] + readonly questions: readonly never[] + readonly projections: Readonly> + } + } + | FixtureProjectionFrame + +type FixturePromptPart = + | { readonly type: 'text'; readonly text: string } + | { + readonly type: 'image' + readonly mediaType: ImageAttachmentRef['mediaType'] + readonly data: string + readonly name?: string + } + +interface FixtureSessionApi { + list(request: { readonly cursor?: string }): Promise> + search( + request: { readonly query: string }, + signal: AbortSignal, + ): Promise> + create(request: { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string + }): Promise> + rename(request: { readonly sessionId: SessionId; readonly title: string }): Promise> + fork(request: { readonly sessionId: SessionId; readonly atSeq?: number }): Promise> + history(request: { + readonly sessionId: SessionId + readonly throughSeq?: number + readonly beforeSeq?: number + readonly maxMessages?: number + }): Promise> + selectModel(request: { + readonly sessionId: SessionId + readonly provider: string + readonly model: string + readonly reasoningEffort?: string + }): Promise> + prompt(request: { + readonly requestId: string + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly FixturePromptPart[] + readonly clientTimeZone?: string + }): Promise> + attachment(request: { + readonly sessionId: SessionId + readonly attachmentId: AttachmentIdType + }): Promise> + updateQueue(request: { + readonly sessionId: SessionId + readonly itemId: MessageId + readonly action: unknown + }): Promise> + cancel(request: { readonly sessionId: SessionId }): Promise> +} + +type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } + +interface WorkspaceView { + readonly workspaceId: WorkspaceId + readonly path: string + readonly title: string + readonly sessionIds: readonly SessionId[] + readonly createdAt: string + readonly updatedAt: string +} + +interface WorkspaceCreateRequest { readonly path: string } +interface WorkspaceCreateValue { readonly workspace: WorkspaceView; readonly created: boolean } +interface WorkspaceRenameRequest { readonly workspaceId: WorkspaceId; readonly title: string } +interface WorkspaceValue { readonly workspace: WorkspaceView } +interface WorkspaceDeleteRequest { readonly workspaceId: WorkspaceId } +interface WorkspaceDeleteValue { readonly deleted: true } +interface WorkspaceInsertBeforeRequest { + readonly workspaceId: WorkspaceId + readonly beforeWorkspaceId?: WorkspaceId +} +interface WorkspaceOrderValue { readonly workspaceIds: readonly WorkspaceId[] } +interface WorkspaceInsertSessionBeforeRequest { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId +} +interface WorkspaceArchiveSessionRequest { readonly sessionId: SessionId } +interface WorkspaceArchiveValue { readonly archivedSessionIds: readonly SessionId[] } + +type WorkspaceFollowFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly items: readonly WorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] + } + } + | { readonly type: 'upsert'; readonly workspace: WorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +interface FixtureWorkspaceApi { + create(request: WorkspaceCreateRequest): Promise> + rename(request: WorkspaceRenameRequest): Promise> + delete(request: WorkspaceDeleteRequest): Promise> + insertBefore(request: WorkspaceInsertBeforeRequest): Promise> + insertSessionBefore(request: WorkspaceInsertSessionBeforeRequest): Promise> + archiveSession(request: WorkspaceArchiveSessionRequest): Promise> +} + +interface FixtureWorkspace { + workspaceId: WorkspaceId + path: string + title: string + sessionIds: SessionId[] + createdAt: string + updatedAt: string +} /** The fake carrier mints like a real one (business code never mints). */ function rpcRequest

(payload: P): RpcRequest

{ @@ -62,7 +341,7 @@ function assistantMessage(content: ContentBlock[], model = 'fx-1'): AssistantMes } function toolResultMessage(callId: string, content: ContentBlock[], isError: boolean): ToolResultMessage { - return createToolResultMessage({ callId: CallId(callId), content, isError }) + return createToolResultMessage({ callId: ToolCallId(callId), content, isError }) } const MARKDOWN_FIXTURE = [ @@ -104,11 +383,9 @@ function sgr(code: number, body: string): string { * basic-16 SGR foreground runs (green, red, bright-black) that must resolve to * `--dsw-*` tokens, a bold run, column-aligned table rows that must scroll * rather than fold, more than DEFAULT_TERMINAL_MAX_LINES (16) lines so the - * height cap collapses the middle. The exit status is authored separately in - * TERMINAL_EXIT_STATUS and deliberately absent from this text: the real bash - * presenter CONSUMES its `[exit code: N]` marker out of the body, because a - * terminal card shows the exit as its own pill and leaving the marker in would - * render it twice (packages/shell/tool-bash/src/render.ts). + * height cap collapses the middle. This constant is the visible body; the call + * site appends the shell result's `[exit code: N]` marker so Client derivation + * can consume it into the terminal status pill. */ const TERMINAL_OUTPUT_FIXTURE = [ sgr(1, 'Running 4 checks'), @@ -135,20 +412,10 @@ const TERMINAL_OUTPUT_FIXTURE = [ ].join('\n') /** - * Exit status for each terminal sample, keyed by its output text. Authored - * alongside the sample rather than parsed back out of its trailing marker, - * which is the bash tool's own job and not something to reimplement here. - */ -const TERMINAL_EXIT_STATUS: Record = { - [TERMINAL_OUTPUT_FIXTURE]: { exitCode: 1 }, -} - -/** - * Structured grep result for the search sample (turn 67): matches grouped by - * file, authored inline because the client-side fixture cannot import the tool - * that produces the canonical value. `truncated` with a larger `total` than the - * retained match count exercises the search card's capped indicator; the file - * with more than CHAT_SEARCH_MAX_LINES rows exercises its head/tail height cap. + * Structured grep metadata for the search sample (turn 67). `truncated` with a + * larger `total` than the retained match count exercises the search card's + * capped indicator; the file with more than CHAT_SEARCH_MAX_LINES rows + * exercises its head/tail height cap. */ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; line: string }[] }[] = [ { @@ -177,13 +444,6 @@ const SEARCH_MATCHES_FIXTURE: { path: string; matches: { lineNumber: number; lin }, ] -/** - * The model-facing grep render text for the sample — what a UI without a search - * card shows, attached as the view's `content`. Mirrors the real grep - * presenter's shape (see formatGrepOutput in dsh-tool-fs-search): a - * `Found X of Y matches` header, the matches grouped under file headers with - * `Line N:` rows, then a spill-recovery footer. - */ const SEARCH_MATCHES_TEXT = [ 'Found 9 of 42 matches', '', @@ -193,10 +453,6 @@ const SEARCH_MATCHES_TEXT = [ '(Full grep result stored at: fixture://spill/grep-66. Read it to see every match.)', ].join('\n') -/** - * Structured glob result for the search sample (turn 68): a flat path list, - * truncated with a larger `total` so the path card shows its capped indicator. - */ const SEARCH_PATHS_FIXTURE = [ 'packages/client/ui-primitives/src/SearchBlock.tsx', 'packages/client/ui-primitives/src/SearchBlock.module.css', @@ -205,25 +461,12 @@ const SEARCH_PATHS_FIXTURE = [ 'packages/client/ui-tool/tests/search-card.client.spec.tsx', ] -/** - * The model-facing glob render text — the newline-joined path list plus a - * spill-recovery footer, mirroring the real glob presenter's shape (see - * formatGlobOutput in dsh-tool-fs-search). - */ const SEARCH_PATHS_TEXT = [ ...SEARCH_PATHS_FIXTURE, '', '(Showing 5 of 23 paths. Full sorted result stored at: fixture://spill/glob-67. Read it to see every path.)', ].join('\n') -/** - * Read-card sample for the read turn: a WINDOW past an offset, so the line - * numbers start above 1 (the card's gutter keeps the file's own numbering) and - * `totalLines` exceeds the window (the card shows a "showing N of M" note). The - * fixture is client-side and cannot import the read tool, so the structured - * window is authored inline exactly as the tool would project it through - * `presentationMeta`. `lang` is a `ts` hint so the shiki path highlights it. - */ const READ_SAMPLE_FIRST_LINE = 41 const READ_SAMPLE_SOURCE = [ 'export interface ReadBlockProps {', @@ -241,18 +484,23 @@ const READ_SAMPLE_SOURCE = [ const READ_SAMPLE_LINES = READ_SAMPLE_SOURCE.map((text, index) => ({ number: READ_SAMPLE_FIRST_LINE + index, text })) const READ_SAMPLE_PATH = 'packages/client/ui-primitives/src/ReadBlock.tsx' const READ_SAMPLE_TOTAL = 180 -const READ_SAMPLE_TEXT = READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`).join('\n') +const READ_SAMPLE_LAST_LINE = READ_SAMPLE_FIRST_LINE + READ_SAMPLE_SOURCE.length - 1 +const READ_SAMPLE_TEXT = [ + `${READ_SAMPLE_PATH}`, + 'file', + '', + ...READ_SAMPLE_SOURCE.map((text, index) => `${READ_SAMPLE_FIRST_LINE + index}: ${text}`), + '', + `(Showing lines ${READ_SAMPLE_FIRST_LINE}-${READ_SAMPLE_LAST_LINE} of ${READ_SAMPLE_TOTAL}. Use offset=${READ_SAMPLE_LAST_LINE + 1} to continue.)`, + '', +].join('\n') /** - * The structured `web_search` result view for the web-search turn, authored inline - * because this client-side fixture cannot import the web tool that projects it. - * The sources exercise the citation list's features: a titled source with a - * snippet and a date, a source with no title (its hostname labels the link) and - * a snippet but no date, and a source with a title and a date but no snippet. - * `truncated` marks the capped indicator. The shape is the contract's own - * search view minus its wire discriminants. + * The `web_search` result metadata for the web-search turn. The sources cover a + * titled source with a snippet and date, a hostname-label fallback, and a + * titled source without a snippet; `truncated` exercises the capped indicator. */ -const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = { +const WEB_SEARCH_META = { answer: 'DeepSeek Harness is a plugin-based agent harness on vendored Cordis where **every capability is a plugin**.', sources: [ { @@ -272,14 +520,14 @@ const WEB_SEARCH_RESULT: Omit, 'card' | 'kind'> = { +/** The `web_fetch` result metadata for the web-fetch turn. */ +const WEB_FETCH_META = { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200, truncated: false, -} +} satisfies JsonValue const DEEPSEEK_REASONING = { efforts: [ @@ -300,7 +548,7 @@ const OPENAI_REASONING = { defaultEffort: 'medium', } -/** Catalog served by `session.models` and `llm.models` alike (fresh copies per call). */ +/** Catalog served by `llm.models` (fresh copies per call). */ function fixtureModelGroups(): ModelProviderGroup[] { return [ { @@ -373,8 +621,7 @@ function buildAlphaLog(): SessionEvent[] { events.push({ seq, time: (time += 800), ...authored }) return seq } - // This resident history represents completed model requests, so retain the - // route capacity that accompanied them just as the live prompt path does. + // Completed fixture requests retain the route capacity recorded with them. push({ type: 'request/context', data: { provider: 'deepseek-official', model: 'deepseek-v4-flash', contextWindow: 128_000 }, @@ -416,10 +663,16 @@ function buildAlphaLog(): SessionEvent[] { } push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } - // Three view-sample turns (60-62) cover the built-in card types. The real filesystem names in - // turns 62-63 also exercise their dedicated generic-row icon/title/path summaries. `echo` above - // stays presenter-less as the unknown fallback. - const toolTurn = (turn: number, name: string, args: string, resultText: string): void => { + // The structured samples use real first-party names and result metadata so + // the fixture follows the same event-to-card path as a persisted Session. + // `echo` above remains the unknown-tool fallback. + const toolTurn = ( + turn: number, + name: string, + args: string, + resultText: string, + resultMeta?: JsonValue, + ): void => { const callId = `fx-call-${turn}` push({ type: 'turn/start', data: { turn } }) push({ type: 'user/message', surfaceOp: 'append', data: userMessage(text(`问题 ${turn}:${name} 样本。`)) }) @@ -429,23 +682,66 @@ function buildAlphaLog(): SessionEvent[] { data: { turn, step: 0, message: assistantMessage([{ type: 'tool-call', id: callId, name, arguments: args } as ContentBlock]) }, }) push({ type: 'tool/call', data: { turn, step: 0, callId, name, arguments: args } }) - push({ type: 'tool/result', surfaceOp: 'append', data: { turn, step: 0, message: toolResultMessage(callId, text(resultText), false) } }) + push({ + type: 'tool/result', + surfaceOp: 'append', + data: { + turn, + step: 0, + message: toolResultMessage(callId, text(resultText), false), + ...resultMeta === undefined ? {} : { meta: resultMeta }, + }, + }) push({ type: 'step/end', data: { turn, step: 0 } }) push({ type: 'turn/end', data: { turn, reason: { kind: 'completed' } } }) } // A two-line command, so the fixture covers the terminal card's one-row-per- // command-line prompt (and that the card still marks the call exactly once). - toolTurn(60, 'fx-bash', '{"command":"ls -la\\necho done","cwd":"/tmp/fixture"}', 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt') - toolTurn(61, 'fx-write', '{"path":"notes/demo.txt","content":"hello fixture\\n"}', 'wrote notes/demo.txt') - toolTurn(62, 'edit', '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', '已编辑') - toolTurn(63, 'write', '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', '已写入') + toolTurn( + 60, + 'bash', + '{"command":"ls -la\\necho done","description":"fixture 终端样本","workdir":"/tmp/fixture"}', + 'total 2\ndrwxr-xr-x fixture\n-rw-r--r-- demo.txt', + ) + toolTurn( + 61, + 'write', + '{"file_path":"notes/demo.txt","content":"hello fixture\\n"}', + 'wrote notes/demo.txt', + { diffs: [{ path: 'notes/demo.txt', oldText: null, newText: 'hello fixture\n' }] }, + ) + toolTurn( + 62, + 'edit', + '{"file_path":"notes/demo.txt","old_string":"hello","new_string":"hello fixture"}', + '已编辑', + { diffs: [{ path: 'notes/demo.txt', oldText: 'hello', newText: 'hello fixture' }] }, + ) + toolTurn( + 63, + 'write', + '{"file_path":"notes/new-demo.txt","content":"hello fixture\\n"}', + '已写入', + { diffs: [{ path: 'notes/new-demo.txt', oldText: null, newText: 'hello fixture\n' }] }, + ) // Turn 64: a multi-hunk edit — two scattered replacements in one file. Named // `edit` so it lands on the keyed FileMutationRow (the resident diff card the - // single-hunk turn 62 also uses), and file_path `src/config.ts` is the marker - // the presenter reads to emit the two-hunk sample: the card draws one path - // header, the first hunk, a `⋯` gap, then the second (the same-file + // single-hunk turn 62 also uses). Its result metadata carries two scattered + // hunks under one path header, so the card draws the first hunk, a `⋯` gap, + // then the second (the same-file // second-hunk arm turns 62/63 cannot reach). - toolTurn(64, 'edit', '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', '已编辑') + toolTurn( + 64, + 'edit', + '{"file_path":"src/config.ts","old_string":"const timeout = 30","new_string":"const timeout = 60"}', + '已编辑', + { + diffs: [ + { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' }, + { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' }, + ], + }, + ) // Turn 65: one run_code turn with three logged sub-dispatches — the Code // Mode acceptance surface (parent code row + nested native-identical rows, // including an isError sub-call and a bash sub-call that must hit the same @@ -501,52 +797,75 @@ function buildAlphaLog(): SessionEvent[] { ] // Turn 66: the terminal sample turn 60's two clean prompt rows cannot cover — // ANSI SGR coloring, output past the terminal card's height cap, a nested cwd - // whose prompt label is its last segment, and a non-zero exit authored beside - // the sample in TERMINAL_EXIT_STATUS — its body deliberately carries no - // `[exit code: N]` marker, since the real presenter consumes that one out of - // the body. Named `bash`, so it also covers - // the keyed toolview row (turn 60's `fx-bash` covers the render-site fallback - // row) — the two chat-row shapes the terminal card renders in. + // whose prompt label is its last segment, and a non-zero exit. The raw result + // includes an `[exit code: N]` marker below; Client + // derivation consumes it into the status pill before rendering the body. // // Ordered BEFORE the todo turn deliberately: the standing plan retires at the // next `turn/start`, so a turn appended after it would leave the dock's plan // strip empty and take the todo surfaces' own coverage with it. - toolTurn(66, 'bash', '{"command":"pnpm run check","cwd":"/tmp/fixture/deep/nested"}', TERMINAL_OUTPUT_FIXTURE) + toolTurn( + 66, + 'bash', + '{"command":"pnpm run check","description":"fixture 终端样本","workdir":"/tmp/fixture/deep/nested"}', + `${TERMINAL_OUTPUT_FIXTURE}\n[exit code: 1]`, + ) - // Turns 67-68: the search card's two shapes. `grep` emits a `card: 'search'` - // `shape: 'matches'` result view (grouped-by-file matches, truncated with a - // larger `total`), `glob` emits `shape: 'paths'` (a flat path list, likewise - // truncated). Both ride the keyed SearchRow registration under their own - // names; the render-site fallback row is covered by the model derivation - // tests, since every fixture search tool has a keyed row. Ordered before the - // todo turn for the same standing-plan reason the bash turn is. - toolTurn(67, 'grep', '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', SEARCH_MATCHES_TEXT) - toolTurn(68, 'glob', '{"pattern":"**/SearchBlock*","path":"packages/client"}', SEARCH_PATHS_TEXT) + // Turns 67-68 carry the search card's two metadata variants: grouped matches + // and a flat path list, both truncated with a larger pre-cap total. Both use + // the keyed SearchRow registration. They stay before the todo turn for the + // same standing-plan reason as the bash turn. + toolTurn( + 67, + 'grep', + '{"pattern":"SEARCH_MAX_LINES","path":"packages/client"}', + SEARCH_MATCHES_TEXT, + { shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 }, + ) + toolTurn( + 68, + 'glob', + '{"pattern":"**/SearchBlock*","path":"packages/client"}', + SEARCH_PATHS_TEXT, + { shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 }, + ) // Turn 69: the read sample — a WINDOW past an offset so the card draws file // line numbers starting above 1 and a "showing N of M" note (the window is // shorter than READ_SAMPLE_TOTAL), with a `ts` language hint the shiki path // highlights. Named `read`, so it exercises the keyed ReadRow registration. - // The render-site fallback ROW SHAPE (a read call on the generic flattened - // path) is covered by the turn 65 run_code read sub-dispatches, which - // session.ts folds with resultView: null; the fallback-row + read-CARD - // combination is pinned by the web_fetch case in read-card.spec.tsx, not by - // this fixture. The read render intent is result-side only, so its pending - // call stays a generic `kind: 'read'` card; presentResult carries the - // structured window. - toolTurn(69, 'read', `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, READ_SAMPLE_TEXT) + // The run_code sub-dispatches above cover nested read calls without result + // metadata; this top-level result carries the structured window. + toolTurn( + 69, + 'read', + `{"file_path":${JSON.stringify(READ_SAMPLE_PATH)},"offset":${READ_SAMPLE_FIRST_LINE}}`, + READ_SAMPLE_TEXT, + { + path: READ_SAMPLE_PATH, + offset: READ_SAMPLE_FIRST_LINE, + lines: READ_SAMPLE_LINES, + totalLines: READ_SAMPLE_TOTAL, + lang: 'ts', + }, + ) - // Turns 70-71: the web render intent — a web_search whose result view carries - // structured sources plus an answer (the citation list, one source lacking a - // title so its hostname labels the link, the capped indicator on), and a - // web_fetch whose result view carries the fetched URL and its HTTP status. - // Both keep a generic pending call view and add the `web` card only at - // result time, which is the contract's result-only web shape. Named after - // the real tools so they hit the keyed WebRow registration. Ordered BEFORE - // the todo turn for the same reason turn 66 is: the standing plan retires at - // the next turn/start, so a turn after it would empty the dock's plan strip. - toolTurn(70, 'web_search', '{"queries":["deepseek harness architecture"]}', 'Search results for deepseek harness architecture.') - toolTurn(71, 'web_fetch', '{"url":"https://www.deepseek.com/blog/harness-architecture"}', '# Harness architecture\n\nEverything is a plugin.') + // Turns 70-71 carry the web tools' result metadata. They stay before the todo + // turn because a later turn/start retires the standing plan projection. + toolTurn( + 70, + 'web_search', + '{"queries":["deepseek harness architecture"]}', + 'Search results for deepseek harness architecture.', + WEB_SEARCH_META, + ) + toolTurn( + 71, + 'web_fetch', + '{"url":"https://www.deepseek.com/blog/harness-architecture"}', + '# Harness architecture\n\nEverything is a plugin.', + WEB_FETCH_META, + ) // Turn 72: max-tokens sample — the provider ends the turn at its output cap // mid-sentence, so the chat flow must render the turn-max-tokens notice @@ -599,151 +918,6 @@ function buildAlphaLog(): SessionEvent[] { return events as unknown as SessionEvent[] } -/** Narrows a parsed-JSON field to string; fixture args are authored in-file, so non-strings only mean a typo here. */ -/* v8 ignore next -- the fallback arm is the same in-file-typo guard as the JSON.parse catch above. */ -const str = (value: unknown, fallback = ''): string => typeof value === 'string' ? value : fallback - -/** Fixture presenter registry (mirrors host viewFor): pure derivation, undefined = no view. */ -function presentCall(name: string, argsRaw: string): ToolCallView | undefined { - let args: Record - try { - args = JSON.parse(argsRaw) as Record - } catch { - /* v8 ignore next 2 -- defensive: fixture args are authored in-file as valid JSON; only an in-file typo could reach the catch. */ - return undefined - } - switch (name) { - // Both names present the same terminal card: `fx-bash` lands on the - // render-site fallback row, `bash` on the keyed BashRow registration. - case 'fx-bash': - case 'bash': - return { card: 'terminal', title: str(args.command), cwd: str(args.cwd, '/tmp/fixture'), description: 'fixture 终端样本' } - case 'fx-write': - return { - card: 'diff', title: `Write ${str(args.path)}`, - diffs: [{ path: str(args.path), oldText: null, newText: str(args.content) }], - } - // A read pending call is a GENERIC card (kind: 'read', a follow-along - // location): the read render intent is result-side only, because a call - // carries no file content until execute returns. The rich read card arrives - // in presentResult. - case 'read': - return { card: 'generic', title: `Read ${str(args.file_path)}`, kind: 'read', locations: [{ path: str(args.file_path) }] } - case 'edit': - // The multi-hunk sample (turn 64) is keyed on its file_path, so the two - // scattered hunks share one path header and the card draws the `⋯` gap. - if (str(args.file_path) === 'src/config.ts') { - return { - card: 'diff', title: `Edit ${str(args.file_path)}`, - diffs: [ - { path: str(args.file_path), oldText: 'const timeout = 30', newText: 'const timeout = 60' }, - { path: str(args.file_path), oldText: 'retries: 1', newText: 'retries: 3' }, - ], - } - } - return { - card: 'diff', title: `Edit ${str(args.file_path)}`, - diffs: [{ path: str(args.file_path), oldText: str(args.old_string), newText: str(args.new_string) }], - } - case 'write': - return { - card: 'diff', title: `Write ${str(args.file_path)}`, - diffs: [{ path: str(args.file_path), oldText: null, newText: str(args.content) }], - } - // A search call stays a generic card (kind: 'search'): the structured - // matches/paths exist only after execute, so the search card is result-time - // only (presentResult builds it). This mirrors the real grep/glob presenters. - case 'grep': - return { card: 'generic', title: `Grep ${str(args.pattern)}`, kind: 'search', rawInput: args } - case 'glob': - return { card: 'generic', title: `Glob ${str(args.pattern)}`, kind: 'search', rawInput: args } - // The web tools keep a GENERIC pending card and add the `web` result card - // only at result time (the contract's result-only web shape); their pending - // kind matches the result kind so a call and its result read as one category. - case 'web_search': { - const queries = Array.isArray(args.queries) ? args.queries.filter((query): query is string => typeof query === 'string' && query !== '') : [] - const title = queries.join(', ') - return { card: 'generic', title: `Search ${title}`, kind: 'search', rawInput: args } - } - case 'web_fetch': - return { card: 'generic', title: `Fetch ${str(args.url)}`, kind: 'fetch', rawInput: args } - default: - return undefined // echo et al: the documented no-view fallback path - } -} - -function presentResult(name: string, argsRaw: string, resultText: string): ToolResultView | undefined { - const call = presentCall(name, argsRaw) - if (call === undefined) return undefined - // Search is result-time only: the call stays a generic search card, and the - // result view carries the structured shape the card renders. The view holds no - // result text — a UI without a search card falls back to the raw tool/result - // content — so the truncation recovery footer rides that raw content (the - // `toolTurn` message text), not the view. `total` exceeds the retained count so - // the card shows its capped indicator. - if (name === 'grep') { - return { card: 'search', shape: 'matches', files: SEARCH_MATCHES_FIXTURE, truncated: true, total: 42 } - } - if (name === 'glob') { - return { card: 'search', shape: 'paths', paths: SEARCH_PATHS_FIXTURE, truncated: true, total: 23 } - } - // The read result is the structured window the tool projects through - // `presentationMeta`; the fixture authors it inline (it cannot import the - // tool). Keyed on the name because the read pending call is a generic card, - // so `call.card` alone does not distinguish it from edit/write. - if (name === 'read') { - return { - card: 'read', path: READ_SAMPLE_PATH, offset: READ_SAMPLE_FIRST_LINE, lines: READ_SAMPLE_LINES, - totalLines: READ_SAMPLE_TOTAL, lang: 'ts', content: text(resultText), - } - } - // The web tools keep a generic pending card, so their result card is chosen - // by tool name rather than by the pending card tag: the structured `web` card - // the frontend consumes. The view carries no `content` copy (per the contract - // and the web-result-card note); a capability-less UI falls back to the raw - // `tool/result` content, which this fixture emits from `resultText`. - if (name === 'web_search') { - return { card: 'web', kind: 'search', ...WEB_SEARCH_RESULT } - } - if (name === 'web_fetch') { - return { card: 'web', kind: 'fetch', ...WEB_FETCH_RESULT } - } - switch (call.card) { - case 'terminal': - // The sample's own exit status, authored beside it: re-parsing the - // trailing marker here would duplicate the bash tool's `parseExitStatus`, - // which this client-side fixture cannot import. - return { card: 'terminal', output: resultText, ...(TERMINAL_EXIT_STATUS[resultText] ?? { exitCode: 0 }) } - case 'diff': - return { card: 'diff', diffs: call.diffs } - case 'generic': - return { card: 'generic', content: text(resultText) } - } -} - -/** Host-side viewFor mirror: tool/call presents from its own args; tool/result back-scans the log for the paired call. */ -function viewFor(event: SessionEvent, log: readonly SessionEvent[]): ToolEventView | undefined { - if (event.type === 'tool/call') { - const view = presentCall(event.data.name, event.data.arguments) - return view === undefined ? undefined : { for: 'call', view } - } - if (event.type === 'tool/result') { - const callId = String(event.data.message.source.callId) - for (let i = log.length - 1; i >= 0; i--) { - const candidate = log[i] - /* v8 ignore next -- dense-array guard: i stays within [0, log.length), - so the undefined arm needs a sparse log no code path builds. */ - if (candidate !== undefined && candidate.type === 'tool/call' && String(candidate.data.callId) === callId) { - const resultText = event.data.message.content[0].content.map(b => (b.type === 'text' ? b.text : '')).join('') - const view = presentResult(candidate.data.name, candidate.data.arguments, resultText) - return view === undefined ? undefined : { for: 'result', view } - } - } - return undefined // cross-page unpaired: documented default - } - return undefined -} - /** * Fixture parallel of the plan unit's lifecycle fold. The paired * `command/done` retains successful plan selections and drops failures; @@ -910,7 +1084,7 @@ function sessionStatsOf(log: readonly SessionEvent[]): { break case 'assistant/chunk': if (openStep !== null && openStep.turn === event.data.turn && openStep.step === event.data.step - && openStep.firstTokenTime === null && isTokenDelta(event.data.chunk)) { + && openStep.firstTokenTime === null && isFixtureTokenDelta(event.data.chunk)) { openStep.firstTokenTime = event.time } break @@ -1058,6 +1232,7 @@ function contextPressureOf( function projectionValuesOf(log: readonly SessionEvent[]): Record { const values: Record = {} + values['modelSelection'] = modelSelectionProjectionOf(log) const titleEvent = log.findLast(item => (item as { type: string }).type === 'session/title') if (titleEvent !== undefined) { values['title'] = (titleEvent as unknown as { data: { title: string } }).data.title @@ -1094,20 +1269,64 @@ function projectionValuesOf(log: readonly SessionEvent[]): Record[] { +function modelSelectionProjectionOf(log: readonly SessionEvent[]): { + lastUsed: ModelSelection | null + next: ModelSelection | null +} { + let lastUsed: ModelSelection | null = null + let pending: ModelSelection | null = null + for (const event of log) { + if ((event as { type: string }).type === 'model/selection') { + pending = (event as unknown as { data: ModelSelection }).data + continue + } + if (event.type !== 'request/header') continue + lastUsed = { + provider: event.data.header.config.provider, + model: event.data.header.config.model, + ...(event.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: event.data.header.config.reasoningEffort }), + } + if (sameModelSelection(pending, lastUsed)) pending = null + } + return { lastUsed, next: pending ?? lastUsed } +} + +function sameModelSelection(left: ModelSelection | null, right: ModelSelection | null): boolean { + return left === right || (left !== null && right !== null + && left.provider === right.provider + && left.model === right.model + && left.reasoningEffort === right.reasoningEffort) +} + +/** Host parallel: emit one Session control projection frame per key advanced by the event. */ +function projectionFramesOf( + id: SessionId, + log: readonly SessionEvent[], + event: SessionEvent, +): FixtureProjectionFrame[] { const type = (event as { type: string }).type - const frames: Extract[] = [] + const frames: FixtureProjectionFrame[] = [] + if (type === 'model/selection' || type === 'request/header') { + frames.push({ + type: 'projection', + sessionId: id, + key: 'modelSelection', + value: modelSelectionProjectionOf(log), + seq: event.seq, + }) + } // One usage sample advances both token-meter units. if (usageSampleOf(event) !== undefined) { frames.push( - { type: 'session/projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, - { type: 'session/projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, + { type: 'projection', sessionId: id, key: 'tokenUsage', value: tokenUsageOf(log), seq: event.seq }, + { type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), seq: event.seq }, ) } if (type === 'request/context') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'contextPressure', value: contextPressureOf(log), @@ -1119,7 +1338,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: || type === 'assistant/message' || type === 'tool/result') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'contextBreakdown', value: contextBreakdownOf(log), @@ -1130,7 +1349,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: // (wall times) and on step close (counts). if (type === 'assistant/message' || type === 'tool/result' || type === 'step/end') { frames.push({ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'sessionStats', value: sessionStatsOf(log), @@ -1142,16 +1361,16 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: const values = projectionValuesOf(log) /* v8 ignore next -- the advancing title event is in the log, so the key is present. */ if (!Object.hasOwn(values, 'title')) return [] - return [{ type: 'session/projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] + return [{ type: 'projection', sessionId: id, key: 'title', value: values['title'], seq: event.seq }] } // The goal domain's own durable change advances its projection. if (type === 'goal/change') { - return [{ type: 'session/projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] + return [{ type: 'projection', sessionId: id, key: 'goal', value: backscanGoal(log), seq: event.seq }] } // Standing-plan fold: writes replace the list; turn/start clears it (null). if (type === 'todo/write' || type === 'turn/start') { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'todos', value: backscanTodos(log) ?? null, @@ -1161,7 +1380,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: // Knob fold: any of the three whole-value knob events advances the select. if (type === 'permission/preset' || type === 'sandbox/mode' || type === 'approval/policy') { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'permissions', value: permissionSelectOf(log), @@ -1174,7 +1393,7 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: if (type === 'plan/mode' || (type === 'command/run' && commandData.data.name === 'plan' && typeof commandData.data.args === 'string')) { return [{ - type: 'session/projection', + type: 'projection', sessionId: id, key: 'plan', value: planViewOf(log), @@ -1185,16 +1404,14 @@ function projectionFramesOf(id: SessionId, log: readonly SessionEvent[], event: } /** - * Message-boundary paging (mirrors the host's paging contract): count - * maxMessages messages - * backwards from end, cut at a turn/start boundary. - Entries carry pagination-time views - * (the host analogue computes viewFor per entry at page time). */ + * Message-boundary paging mirrors the Host contract: count `maxMessages` + * backwards from the end and cut at a turn/start boundary. + */ function pageOf( log: readonly SessionEvent[], beforeSeq: number | undefined, maxMessages: number, -): { events: HistoryEntry[]; hasMore: boolean } { +): { records: FixtureHistoryRecord[]; hasMore: boolean } { const end = beforeSeq === undefined ? log.length : Math.max(0, Math.min(beforeSeq, log.length)) let start = 0 let messages = 0 @@ -1208,11 +1425,27 @@ function pageOf( break } } - const events = log.slice(start, end).map((event): HistoryEntry => { - const view = viewFor(event, log) - return view === undefined ? { event } : { event, view } + const records = packChunkRuns(log.slice(start, end)).map((record): FixtureHistoryRecord => { + if (!isChunkRow(record)) return { type: 'event', event: record } + switch (record.type) { + case 'text-chunks': + return { + type: 'chunks', + event: { type: 'chunkrow/text-chunks', seq: record.seq0, time: record.time0, data: record.data }, + } + case 'reasoning-chunks': + return { + type: 'chunks', + event: { type: 'chunkrow/reasoning-chunks', seq: record.seq0, time: record.time0, data: record.data }, + } + case 'tool-call-chunks': + return { + type: 'chunks', + event: { type: 'chunkrow/tool-call-chunks', seq: record.seq0, time: record.time0, data: record.data }, + } + } }) - return { events, hasMore: start > 0 } + return { records, hasMore: start > 0 } } /** Fixture mirror of host session-scoped attachment authorization. */ @@ -1424,8 +1657,8 @@ function backscanGoal(log: readonly SessionEvent[]): FxGoalProjection | null { return null } -interface StreamConn { - push(envelope: RpcRequest): void +interface StreamConn { + push(value: Value): void } interface ReasoningChunkStormState { @@ -1456,13 +1689,13 @@ export interface FixtureOptions { * outside the loop — a per-iteration {once:true} listener never fires for non-final rounds and * piles up for the stream's lifetime). breakNow force-ends the stream without the * client's signal (timing hook: simulated connection loss). */ -class FxInbox implements StreamConn { - private readonly inbox: RpcRequest[] = [] +class FxInbox implements StreamConn { + private readonly inbox: Value[] = [] private wake: (() => void) | null = null private broken = false - push(envelope: RpcRequest): void { - this.inbox.push(envelope) + push(value: Value): void { + this.inbox.push(value) this.wake?.() } @@ -1476,12 +1709,12 @@ class FxInbox implements StreamConn { return !signal.aborted && !this.broken } - async *drain(signal: AbortSignal): AsyncGenerator> { + async *drain(signal: AbortSignal): AsyncGenerator { const onAbort = (): void => this.wake?.() signal.addEventListener('abort', onAbort) try { while (this.isLive(signal)) { - while (this.inbox.length > 0) yield this.inbox.shift() as RpcRequest + while (this.inbox.length > 0) yield this.inbox.shift() as Value if (!this.isLive(signal)) break await new Promise((resolve) => { this.wake = resolve @@ -1524,7 +1757,7 @@ export function createFixtureFaces(options: FixtureOptions = {}): FixtureWorld { /** Build the fixture's legacy API and Remote RPC faces over one state graph. */ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // The resident fixture sessions all carry history, so none of them is blank. - const sessions: SessionSummary[] = options.empty ? [] : [ + const sessions: FixtureSessionSummary[] = options.empty ? [] : [ { sessionId: sid('fx-alpha'), updatedAt: Date.now(), running: true, blank: false, cwd: '/tmp/fixture' }, { sessionId: sid('fx-beta'), updatedAt: Date.now() - 60_000, running: false, blank: false, parentSessionId: sid('fx-alpha'), cwd: '/tmp/fixture' }, { sessionId: sid('fx-gamma'), updatedAt: Date.now() - 120_000, running: false, blank: false, cwd: '/tmp/fixture' }, @@ -1557,14 +1790,13 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { let fixtureDefaultPreset = 'standard' const nextTurn = new Map([[sid('fx-alpha'), 75]]) let nextSession = 1 - let nextRpc = 1 let attachedSessions = options.empty ? 0 : 1 // Workspace entities mirroring the host registry: the fixture sessions all // live under one workspace, whose account carries them in attach order. const wid = (raw: string): WorkspaceId => raw as WorkspaceId const fixtureEpoch = new Date(Date.now() - 300_000).toISOString() const FIXTURE_HOME = '/home/fixture' - const workspaces: WorkspaceView[] = options.empty ? [] : [{ + const workspaces: FixtureWorkspace[] = options.empty ? [] : [{ workspaceId: wid('fx-ws-fixture'), path: '/tmp/fixture', title: 'fixture', @@ -1583,6 +1815,17 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { // Registry-global archive set mirroring the host: archived sessions keep // their workspace accounting slot and only grouping surfaces hide them. const archivedSessionIds: SessionId[] = [] + const workspaceSnapshot = (workspace: FixtureWorkspace): WorkspaceView => ({ + ...workspace, + sessionIds: [...workspace.sessionIds], + }) + const workspaceBaseline = (): Extract => ({ + type: 'baseline', + value: { + items: workspaces.map(workspaceSnapshot), + archivedSessionIds: [...archivedSessionIds], + }, + }) // In-memory browse tree behind the fixture's `browse` picker capability — // deterministic content mirroring the design mock so assembled Web tests @@ -1613,15 +1856,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } return crumbs } - const mint = (): ReturnType => RpcId(`fx-rpc-${nextRpc++}`) - /** Resident pending approval (stable rpcId: every mux open replays the same id while unanswered, matching host replay semantics). */ - const pendingApprovalRpcId = mint() - const pendingApprovalId = 'fx-approval-1' as Extract['approvalId'] - /** Cleared once answered through respond; replay stops and approval/resolved is broadcast. */ - let approvalPending = true - const pendingQuestionRpcId = mint() - let questionPending = true - const fixtureQuestions: Extract['questions'] = [ + /** Resident waterfalls retain their event ids across Remote Event generations. */ + const pendingApprovalEventId = 'fx-interaction-approval' + let approvalPending = !options.empty + const pendingQuestionEventId = 'fx-interaction-question' + let questionPending = !options.empty + const fixtureQuestions: readonly FixtureQuestionItem[] = [ { id: 'harness-profile', header: '偏好', @@ -1655,13 +1895,24 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, ] - const muxConns = new Set>() - const hostConns = new Set>() - const emitMux = (frame: MuxFrame): void => { - for (const conn of muxConns) conn.push({ rpcId: mint(), payload: frame }) + const controlConns = new Set>() + const followConns = new Map>>() + const workspaceConns = new Set>() + const remoteEventConns = new Map>() + const emitControl = (frame: FixtureControlFrame): void => { + for (const conn of controlConns) conn.push(frame) } - const emitHost = (frame: HostFrame): void => { - for (const conn of hostConns) conn.push({ rpcId: mint(), payload: frame }) + const emitWorkspace = (frame: Exclude): void => { + for (const conn of workspaceConns) conn.push(frame) + } + const emitRemote = (event: string, args: readonly unknown[]): void => { + for (const conn of remoteEventConns.values()) conn.push({ type: 'emit', event, args }) + } + const emitRemoteFrame = (frame: FixtureRemoteEventFrame): void => { + for (const conn of remoteEventConns.values()) conn.push(frame) + } + const emitFollow = (sessionId: SessionId, entry: FixtureHistoryEntry): void => { + for (const conn of followConns.get(sessionId) ?? []) conn.push(entry) } /** OK response echoing the caller's rpcId (contract: responses always backfill, never mint). */ @@ -1672,7 +1923,15 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return Promise.resolve({ rpcId: request.rpcId, result: { ok: false, error } }) } - const summaryOf = (id: SessionId): SessionSummary | undefined => sessions.find(s => s.sessionId === id) + function sessionOk(value: T): Promise> { + return Promise.resolve({ ok: true, value }) + } + + function sessionErr(error: ConnectionRpcFailure): Promise> { + return Promise.resolve({ ok: false, error }) + } + + const summaryOf = (id: SessionId): FixtureSessionSummary | undefined => sessions.find(s => s.sessionId === id) /** Shared session guard for sessionId-addressed catalog routes: the error * response when the session is unknown, undefined when it exists. */ const requireSession = (request: RpcRequest<{ sessionId: SessionId }>): Promise> | undefined => { @@ -1683,11 +1942,21 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { details: { sessionId: request.payload.sessionId }, }) } + const requireRemoteSession = ( + request: { readonly sessionId: SessionId }, + ): Promise> | undefined => { + if (summaryOf(request.sessionId) !== undefined) return undefined + return sessionErr({ + code: 'session-not-found', + message: `no session ${request.sessionId}`, + details: { sessionId: request.sessionId }, + }) + } const setRunning = (id: SessionId, running: boolean): void => { const summary = summaryOf(id) if (summary === undefined || summary.running === running) return summary.running = running - emitHost({ type: 'host/session-status', sessionId: id, running }) + emitRemote('api-session/status', [id, running]) } const logOf = (id: SessionId): SessionEvent[] => { let log = logs.get(id) @@ -1701,16 +1970,14 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { const log = logOf(id) const event = { seq: log.length, time: Date.now(), ...e } as unknown as SessionEvent log.push(event) - // Emission-time view derivation (mirrors the host's live path). - const view = viewFor(event, log) - /* v8 ignore next 3 -- the view-present arm needs a live tool/call emission, - but the fixture replay produces text-only turns; view vocabulary is - exercised through the history samples (turns 60-62). */ - emitMux(view === undefined - ? { type: 'session/event', sessionId: id, event } - : { type: 'session/event', sessionId: id, event, view }) + emitFollow(id, { type: 'event', event }) // Host eager-drive parallel: a unit-advancing event pushes its finished value. - for (const frame of projectionFramesOf(id, log, event)) emitMux(frame) + for (const frame of projectionFramesOf(id, log, event)) emitControl(frame) + if (event.type === 'user/message' && event.data.source.kind === 'user') { + const summary = summaryOf(id) + if (summary !== undefined) summary.updatedAt = event.time + emitRemote('api-session/activity', [id, event.time]) + } } /** Append one durable goal/change (host GoalService parallel). */ @@ -1866,6 +2133,51 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) /** Canonical fixture implementation of the generated Goal Remote contract. */ + /** Canonical fixture implementation of the generated reference-discovery Remote contracts. */ + const referenceRemotes = { + files(id: SessionId, query: string): RpcResult<{ path: string; kind: 'file' | 'directory' }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const items = [ + { path: 'notes', kind: 'directory' as const }, + { path: 'README.md', kind: 'file' as const }, + { path: 'notes/demo.txt', kind: 'file' as const }, + ].filter(item => item.path.toLocaleLowerCase().includes(needle)) + return { ok: true, value: items } + }, + sessions(id: SessionId, query: string): RpcResult<{ + sessionId: SessionId + label: string + cwd?: string + createdAt: number + mention: string + }[]> { + const missing = requireGoalSession(id) + if (missing !== undefined) return missing + const needle = query.toLocaleLowerCase() + const value = sessions + .filter(item => item.sessionId !== id) + .filter(item => String(item.sessionId).toLocaleLowerCase().includes(needle) + || item.cwd?.toLocaleLowerCase().includes(needle) === true) + .map((item) => { + const label = item.sessionId === sid('fx-beta') ? 'Fixture child session' : String(item.sessionId) + const encoded = btoa(JSON.stringify(item.sessionId)) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/u, '') + return { + sessionId: item.sessionId, + label, + ...item.cwd === undefined ? {} : { cwd: item.cwd }, + createdAt: item.updatedAt, + mention: `@[${label}](dsh-session:${encoded})`, + } + }) + return { ok: true, value } + }, + } + const goalRemotes = { create(id: SessionId, request: { objective: string; maxGoalRounds?: number }): RpcResult<{ ref: FxGoalRef }> { const missing = requireGoalSession(id) @@ -1961,22 +2273,86 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { return { ok: true, value: goalView(projection) } } - const mapGoalResult = (result: RpcResult, map: (value: T) => U): RpcResult => ( - result.ok ? { ok: true, value: map(result.value) } : result - ) - - const goalRefResult = (result: RpcResult): RpcResult<{ ref: { id: never; revision: number } }> => ( - mapGoalResult(result, view => ({ ref: { id: view.id as never, revision: view.revision } })) - ) - - const legacyGoalResponse = (request: RpcRequest

, result: RpcResult): Promise> => ( - Promise.resolve({ rpcId: request.rpcId, result }) - ) + /** Canonical fixture implementation of the generated AgentPresets Remote contract. */ + const presetRemotes = { + // Both trusts appear, because a surface must present a locally authored + // preset differently from one the deployment vetted. + list(): RpcResult<{ presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[]; authorable: boolean }> { + return { + ok: true, + value: { + presets: [...fixturePresets].map(([id, preset]) => ({ + id, + trust: preset.trust, + isDefault: id === fixtureDefaultPreset, + })), + authorable: true, + }, + } + }, + select(_id: SessionId, agentPreset: string): RpcResult { + fixtureDefaultPreset = agentPreset + return { ok: true, value: agentPreset } + }, + read(agentPreset: string): RpcResult<{ agentPreset: string; trust: 'system' | 'user'; content: string }> { + const preset = fixturePresets.get(agentPreset) + if (preset === undefined) { + return { + ok: false, + error: { + code: 'agent-preset-not-found', + message: `unknown agent preset "${agentPreset}"`, + details: { agentPreset, available: [...fixturePresets.keys()] }, + }, + } + } + return { ok: true, value: { agentPreset, trust: preset.trust, content: preset.content } } + }, + copy(from: string, id: string): RpcResult { + const source = fixturePresets.get(from) + if (source === undefined) { + return { + ok: false, + error: { + code: 'agent-preset-not-found', + message: `unknown agent preset "${from}"`, + details: { agentPreset: from, available: [...fixturePresets.keys()] }, + }, + } + } + if (fixturePresets.has(id)) { + return { + ok: false, + error: { + code: 'agent-preset-invalid', + message: `agent preset "${id}" already exists`, + details: { agentPreset: id, reason: 'already exists' }, + }, + } + } + fixturePresets.set(id, { trust: 'user', content: source.content }) + return { ok: true, value: undefined } + }, + deletePreset(id: string): RpcResult { + if (fixturePresets.get(id)?.trust === 'system') { + return { + ok: false, + error: { + code: 'agent-preset-read-only', + message: `agent preset "${id}" ships with the deployment`, + details: { agentPreset: id, reason: 'it ships with the deployment' }, + }, + } + } + fixturePresets.delete(id) + return { ok: true, value: undefined } + }, + } /** At most one in-flight replay per session; cancel clears it. */ const replays = new Map; finish(aborted: boolean): void }>() - /** history transit delay (timing hooks below); the page snapshot is taken at request time, like a real host. */ + /** History transit delay; the page snapshot is taken at request time. */ let historyDelayMs = 0 /** One-shot history failure (timing hook: a pre-disconnect history request already doomed when reconnect lands). */ let failNextHistory = false @@ -1987,10 +2363,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { /** The single opt-in browser stress producer; normal fixture journeys never start it. */ let activeReasoningChunkStorm: ReasoningChunkStormState | null = null - // Timing-acceptance hooks (browser test backdoor): the in-memory fixture is - // ideally timed. These let - // browser acceptance runs create slow-history, lost-frame, and reconnect - // windows a real host produces naturally. + // Browser-only timing hooks for slow history, lost frames, and reconnects. const timingHooks = { setHistoryDelay(ms: number): void { historyDelayMs = ms @@ -1999,7 +2372,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { failNextHistory(): void { failNextHistory = true }, - /** Log append + mux emit (the normal live path). */ + /** Log append plus follow-stream delivery (the normal live path). */ appendUser(id: string, msg: string): void { append(sid(id), { type: 'user/message', surfaceOp: 'append', data: userMessage(text(msg)) }) }, @@ -2167,7 +2540,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { append(sessionId, { type: 'turn/end', data: { turn: scenario.turn, reason: { kind: 'completed' } } }) setRunning(sessionId, false) }, - /** Log append WITHOUT the mux emit: a frame lost in transit — history still serves it, the client must repull. */ + /** Log append without follow delivery: a frame lost in transit that page repair must recover. */ appendSilent(id: string, msg: string): void { const log = logOf(sid(id)) log.push({ type: 'user/message', surfaceOp: 'append', seq: log.length, time: Date.now(), data: userMessage(text(msg)) } as unknown as SessionEvent) @@ -2218,358 +2591,675 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { replays.set(id, { timer: setTimeout(tick, 80), finish }) } - const api: ApiProxy = { - sessions: { - list: request => ok(request, { items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), - search: (request, signal) => { - if (signal.aborted) { - return err(request, { - code: 'cancelled', - message: 'fixture session search was aborted', - details: {}, - }) - } - const query = searchTokenSpans(request.payload.query).tokens.map(token => token.value) - const matches = sessions.flatMap((summary) => { - const log = logs.get(summary.sessionId) ?? [] - const current = new Set(foldSurface(log).nodes) - const best = log.flatMap((event): FixtureSearchCandidate[] => { - if (!current.has(event.seq)) return [] - const eventText = searchEventText(event) - const document = searchTokenSpans(eventText) - const match = phraseMatch(document.tokens, query) - if (match.count === 0) return [] - return [{ - sessionId: summary.sessionId, - seq: event.seq, - time: event.time, - text: document.text, - matchCount: match.count, - matchStart: match.start, - matchEnd: match.end, - documentLength: Array.from(eventText).length, - }] - }).sort(compareSearchCandidates)[0] - return best === undefined ? [] : [best] - }).sort(compareSearchCandidates) - return ok(request, { - items: matches.slice(0, SESSION_SEARCH_RESULT_LIMIT).map(match => ({ - sessionId: match.sessionId, - snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), - })), - hasMore: matches.length > SESSION_SEARCH_RESULT_LIMIT, + const sessionApi: FixtureSessionApi = { + list: _request => sessionOk({ items: [...sessions].sort((a, b) => b.updatedAt - a.updatedAt) }), + search: (request, signal) => { + if (signal.aborted) { + return sessionErr({ + code: 'cancelled', + message: 'fixture session search was aborted', + details: {}, }) - }, - create: async (request) => { - const workspace = request.payload.workspaceId === undefined - ? undefined - : workspaces.find(w => w.workspaceId === request.payload.workspaceId) - if (request.payload.workspaceId !== undefined && workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${request.payload.workspaceId}`, - details: { workspaceId: request.payload.workspaceId }, - }) - } - const cwd = workspace?.path ?? request.payload.cwd ?? '/tmp/fixture' - const requestedId = request.payload.sessionId - const attachWorkspace = (sessionId: SessionId): void => { - /* v8 ignore next -- callers enter only when a target Workspace exists. */ - if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return - workspace.sessionIds = [sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - const attachFailure = ( - sessionId: SessionId, - workspaceId: WorkspaceId, - ): Promise> => err(request, { - code: 'workspace-attach-failed' as const, - message: `fixture rejected Workspace attachment for ${sessionId}`, - details: { sessionId, workspaceId }, + } + const query = searchTokenSpans(request.query).tokens.map(token => token.value) + const matches = sessions.flatMap((summary) => { + const log = logs.get(summary.sessionId) ?? [] + const current = new Set(foldSurface(log).nodes) + const best = log.flatMap((event): FixtureSearchCandidate[] => { + if (!current.has(event.seq)) return [] + const eventText = searchEventText(event) + const document = searchTokenSpans(eventText) + const match = phraseMatch(document.tokens, query) + if (match.count === 0) return [] + return [{ + sessionId: summary.sessionId, + seq: event.seq, + time: event.time, + text: document.text, + matchCount: match.count, + matchStart: match.start, + matchEnd: match.end, + documentLength: Array.from(eventText).length, + }] + }).sort(compareSearchCandidates)[0] + return best === undefined ? [] : [best] + }).sort(compareSearchCandidates) + return sessionOk({ + items: matches.slice(0, FIXTURE_SESSION_SEARCH_RESULT_LIMIT).map(match => ({ + sessionId: match.sessionId, + snippet: searchSnippet(match.text, match.matchStart, match.matchEnd), + })), + hasMore: matches.length > FIXTURE_SESSION_SEARCH_RESULT_LIMIT, + }) + }, + create: async (request) => { + const workspace = request.workspaceId === undefined + ? undefined + : workspaces.find(w => w.workspaceId === request.workspaceId) + if (request.workspaceId !== undefined && workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, }) - if (requestedId !== undefined) { - const existing = summaryOf(requestedId) - if (existing !== undefined) { - if (existing.cwd !== cwd) { - return err(request, { - code: 'session-conflict', - message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, - details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, - }) - } - if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { - if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) - attachWorkspace(requestedId) - } - return ok(request, { sessionId: requestedId }) + } + const cwd = workspace?.path ?? request.cwd ?? '/tmp/fixture' + const requestedId = request.sessionId + const attachWorkspace = (sessionId: SessionId): void => { + /* v8 ignore next -- callers enter only when a target Workspace exists. */ + if (workspace === undefined || workspace.sessionIds.includes(sessionId)) return + workspace.sessionIds = [sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + const attachFailure = ( + sessionId: SessionId, + workspaceId: WorkspaceId, + ): Promise> => sessionErr({ + code: 'workspace-attach-failed' as const, + message: `fixture rejected Workspace attachment for ${sessionId}`, + details: { sessionId, workspaceId }, + }) + if (requestedId !== undefined) { + const existing = summaryOf(requestedId) + if (existing !== undefined) { + if (existing.cwd !== cwd) { + return sessionErr({ + code: 'session-conflict', + message: `session ${requestedId} already uses ${existing.cwd ?? 'no cwd'}`, + details: { sessionId: requestedId, requestedCwd: cwd, ...existing.cwd === undefined ? {} : { existingCwd: existing.cwd } }, + }) } + if (workspace !== undefined && !workspace.sessionIds.includes(requestedId)) { + if (options.failWorkspaceAttach) return attachFailure(requestedId, workspace.workspaceId) + attachWorkspace(requestedId) + } + return sessionOk({ sessionId: requestedId }) } - const created: SessionSummary = { - sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, - } - sessions.push(created) - modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) - attachedSessions += 1 - const emitSession = (): void => { - // Mirrors the host: the frame fires at creation, so blank is constantly true. - emitHost({ type: 'host/session-added', sessionId: created.sessionId, blank: true, cwd }) - } - if (workspace !== undefined && options.failWorkspaceAttach) { - emitSession() - return attachFailure(created.sessionId, workspace.workspaceId) - } - if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { - attachWorkspace(created.sessionId) - emitSession() - } else { - emitSession() - if (workspace !== undefined) attachWorkspace(created.sessionId) - } - if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') - return ok(request, { sessionId: created.sessionId }) - }, - rename: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId, title } = request.payload - const normalized = title.trim().replace(/\s+/g, ' ') - if (normalized.length === 0) { - return err(request, { - code: 'title-invalid', - message: 'session title must contain visible characters', - details: { sessionId }, - }) - } - // The append emits the session/event and its session/projection frame - // (host parallel); the unary response settles the caller first. - append(sessionId, { - type: 'session/title', - data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + } + const created: FixtureSessionSummary = { + sessionId: requestedId ?? sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: true, cwd, + } + sessions.push(created) + modelSelections.set(created.sessionId, { provider: 'deepseek-official', model: 'deepseek-v4-flash' }) + attachedSessions += 1 + const emitSession = (): void => { + emitRemote('api-session/added', [created]) + } + if (workspace !== undefined && options.failWorkspaceAttach) { + emitSession() + return attachFailure(created.sessionId, workspace.workspaceId) + } + if (workspace !== undefined && options.createFrameOrder === 'workspace-first') { + attachWorkspace(created.sessionId) + emitSession() + } else { + emitSession() + if (workspace !== undefined) attachWorkspace(created.sessionId) + } + if (options.dropSessionCreateResponse) throw new Error('fixture: dropped session.create response after publication') + return sessionOk({ sessionId: created.sessionId }) + }, + rename: (request) => { + const missing = requireRemoteSession(request) + if (missing !== undefined) return missing + const { sessionId, title } = request + const normalized = title.trim().replace(/\s+/g, ' ') + if (normalized.length === 0) { + return sessionErr({ + code: 'title-invalid', + message: 'session title must contain visible characters', + details: { sessionId }, }) - const appended = logOf(sessionId).at(-1) as SessionEvent - return ok(request, { title: normalized, seq: appended.seq }) - }, - fork: (request) => { - const { sessionId, atSeq } = request.payload - const source = summaryOf(sessionId) - if (source === undefined) { - return err(request, { - code: 'session-not-found', - message: `no session ${sessionId}`, - details: { sessionId }, - }) - } - const log = logs.get(sessionId) ?? [] - const lastSeq = log.at(-1)?.seq ?? -1 - const anchoredBoundary = atSeq === undefined - ? undefined - : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) - const boundary = anchoredBoundary + } + // The append emits the durable event and its control projection frame; + // the unary response settles the caller first. + append(sessionId, { + type: 'session/title', + data: { title: normalized, messageSeqs: [], source: { kind: 'user' } }, + }) + const appended = logOf(sessionId).at(-1) as SessionEvent + return sessionOk({ title: normalized, seq: appended.seq }) + }, + fork: (request) => { + const { sessionId, atSeq } = request + const source = summaryOf(sessionId) + if (source === undefined) { + return sessionErr({ + code: 'session-not-found', + message: `no session ${sessionId}`, + details: { sessionId }, + }) + } + const log = logs.get(sessionId) ?? [] + const lastSeq = log.at(-1)?.seq ?? -1 + const anchoredBoundary = atSeq === undefined + ? undefined + : log.find(e => e.type === 'turn/end' && e.seq >= atSeq) + const boundary = anchoredBoundary ?? (atSeq === undefined || atSeq > lastSeq ? log.findLast(e => e.type === 'turn/end') : undefined) - if (boundary === undefined) { - return err(request, { - code: 'fork-unavailable', - message: atSeq !== undefined && atSeq <= lastSeq - ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` - : `session ${sessionId} has no completed turn`, - details: { sessionId }, - }) - } - let cut = boundary.seq + 1 - while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ - const child: SessionSummary = { - sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, - } - logs.set(child.sessionId, log.slice(0, cut)) - sessions.push(child) - emitHost({ - type: 'host/session-added', sessionId: child.sessionId, blank: false, - parentSessionId: sessionId, - ...source.cwd === undefined ? {} : { cwd: source.cwd }, + if (boundary === undefined) { + return sessionErr({ + code: 'fork-unavailable', + message: atSeq !== undefined && atSeq <= lastSeq + ? `session ${sessionId} has not completed the turn containing event ${String(atSeq)}` + : `session ${sessionId} has no completed turn`, + details: { sessionId }, }) - const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) - if (workspace !== undefined) { - workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { sessionId: child.sessionId }) - }, - history: async (request) => { - 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 projections block (host parallel: one consistent - // cut over the registered units; asOfSeq = window tail seq, -1 on an - // empty log — the host's session.seq-1 convention). - const projections = request.payload.beforeSeq === undefined - ? { asOfSeq: log.length - 1, values: projectionValuesOf(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, ...projections === undefined ? {} : { projections } }) - }, - models: request => ok(request, { - current: modelSelections.get(request.payload.sessionId) - ?? { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - // The fixture's routes all serve; a surface exercising the blocked - // posture drives it through its own stub. - routable: true, - groups: fixtureModelGroups(), - failures: [], - }), - selectModel: (request) => { - const selected: ModelSelection = { - provider: request.payload.provider, - model: request.payload.model, - ...request.payload.reasoningEffort === undefined - ? {} - : { reasoningEffort: request.payload.reasoningEffort }, - } - modelSelections.set(request.payload.sessionId, selected) - return ok(request, { selected }) - }, - prompt: (request) => { - const { sessionId: id, mode, content } = request.payload - const summary = summaryOf(id) - if (summary === undefined) { - return err(request, { code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) - } - if (options.rejectPrompt) { - if (content.some(block => block.type === 'image')) { - return err(request, { - code: 'attachment-error', - message: 'fixture: image side exceeds the deployment limit', - details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, - }) - } - return err(request, { - code: 'agent-busy', - message: 'fixture: prompt rejected before acceptance', - details: { reason: 'fixture-prompt-rejection' }, + } + let cut = boundary.seq + 1 + while (cut < log.length && log[cut]?.type !== 'turn/start') cut++ + const child: FixtureSessionSummary = { + sessionId: sid(`fx-${nextSession++}`), updatedAt: Date.now(), running: false, blank: false, + parentSessionId: sessionId, + ...source.cwd === undefined ? {} : { cwd: source.cwd }, + } + logs.set(child.sessionId, log.slice(0, cut)) + sessions.push(child) + emitRemote('api-session/added', [child]) + const workspace = workspaces.find(w => w.sessionIds.includes(sessionId)) + if (workspace !== undefined) { + workspace.sessionIds = [child.sessionId, ...workspace.sessionIds] + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ sessionId: child.sessionId }) + }, + history: async (request) => { + const log = logs.get(request.sessionId) ?? [] + const throughSeq = request.throughSeq ?? log.length - 1 + const boundedLog = log.slice(0, throughSeq + 1) + // Snapshot at request time, then deliver after the transit delay. + const page = pageOf(boundedLog, request.beforeSeq, request.maxMessages ?? 50) + 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 sessionOk(page) + }, + selectModel: (request) => { + const selected: ModelSelection = { + provider: request.provider, + model: request.model, + ...request.reasoningEffort === undefined + ? {} + : { reasoningEffort: request.reasoningEffort }, + } + append(request.sessionId, { type: 'model/selection', data: selected }) + modelSelections.set(request.sessionId, selected) + return sessionOk({ selected }) + }, + prompt: (request) => { + const { sessionId: id, mode, content } = request + const summary = summaryOf(id) + if (summary === undefined) { + return sessionErr({ code: 'session-not-found', message: `no session ${id}`, details: { sessionId: id } }) + } + if (options.rejectPrompt) { + if (content.some(block => block.type === 'image')) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture: image side exceeds the deployment limit', + details: { reason: 'IMAGE_DIMENSION_TOO_LARGE' }, }) } - summary.updatedAt = Date.now() - // First accepted prompt appends events: the summary stops being blank. - summary.blank = false - const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') - const durable: ContentBlock[] = content.map((block) => { - if (block.type === 'text') return block - const attachment: ImageAttachmentRef = { - attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, - mediaType: block.mediaType, - bytes: Math.max( - 1, - Math.floor(block.data.length * 3 / 4) + return sessionErr({ + code: 'agent-busy', + message: 'fixture: prompt rejected before acceptance', + details: { reason: 'fixture-prompt-rejection' }, + }) + } + summary.updatedAt = Date.now() + // First accepted prompt appends events: the summary stops being blank. + summary.blank = false + const userText = content.map(b => (b.type === 'text' ? b.text : '')).join('') + const durable: ContentBlock[] = content.map((block) => { + if (block.type === 'text') return block + const attachment: ImageAttachmentRef = { + attachmentId: `fixture:${randomUuid()}` as AttachmentIdType, + mediaType: block.mediaType, + bytes: Math.max( + 1, + Math.floor(block.data.length * 3 / 4) - (block.data.endsWith('==') ? 2 : block.data.endsWith('=') ? 1 : 0), - ), - width: 160, - height: 90, - ...block.name === undefined ? {} : { name: block.name }, - } - attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) - return { type: 'image', attachment } - }) - if (mode === 'steer' && replays.has(id)) { - // Steering: the durable user/message lands inside the current turn; the replay continues. - append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) - return ok(request, { accepted: true as const }) - } - const turn = nextTurn.get(id) ?? 0 - nextTurn.set(id, turn + 1) - setRunning(id, true) - append(id, { type: 'turn/start', data: { turn } }) - // Boundary flush parallel (the host's step/start observer): an outstanding - // /plan selection commits as plan/mode inside the opened turn. - const plan = foldPlan(logOf(id)) - if (plan.wanted !== null && plan.wanted !== plan.active) { - append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + ), + width: 160, + height: 90, + ...block.name === undefined ? {} : { name: block.name }, } + attachments.set(String(attachment.attachmentId), { attachment, data: block.data }) + return { type: 'image', attachment } + }) + if (mode === 'steer' && replays.has(id)) { + // Steering: the durable user/message lands inside the current turn; the replay continues. append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) - // Capacity parallel of the host token-meter's request/context record: - // log-only, appended inside the open turn, and deduplicated against the - // route already recorded (the fixture never varies contextWindow). - const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } - if (lastRequestContext(logOf(id))?.model !== selection.model) { - append(id, { - type: 'request/context', - data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, - }) + return sessionOk({ accepted: true as const }) + } + const turn = nextTurn.get(id) ?? 0 + nextTurn.set(id, turn + 1) + setRunning(id, true) + append(id, { type: 'turn/start', data: { turn } }) + // Boundary flush parallel (the host's step/start observer): an outstanding + // /plan selection commits as plan/mode inside the opened turn. + const plan = foldPlan(logOf(id)) + if (plan.wanted !== null && plan.wanted !== plan.active) { + append(id, { type: 'plan/mode', data: { active: plan.wanted } }) + } + append(id, { type: 'user/message', surfaceOp: 'append', data: userMessage(durable) }) + // Capacity parallel of the host token-meter's request/context record: + // log-only, appended inside the open turn, and deduplicated against the + // route already recorded (the fixture never varies contextWindow). + const selection = modelSelections.get(id) ?? { provider: 'deepseek', model: 'deepseek-v4-flash' } + const previousHeader = logOf(id).findLast(event => event.type === 'request/header') + const previousSelection = previousHeader?.type === 'request/header' + ? { + provider: previousHeader.data.header.config.provider, + model: previousHeader.data.header.config.model, + ...(previousHeader.data.header.config.reasoningEffort === undefined + ? {} + : { reasoningEffort: previousHeader.data.header.config.reasoningEffort }), } - startReply( - id, - turn, - userText === 'render markdown' - ? MARKDOWN_FIXTURE - : userText === 'report model' - ? (() => { - const selection = modelSelections.get(id) - return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` + : null + if (!sameModelSelection(previousSelection, selection)) { + append(id, { + type: 'request/header', + data: { + header: { config: selection }, + reason: previousHeader === undefined ? 'initial' : 'change', + }, + }) + } + if (lastRequestContext(logOf(id))?.model !== selection.model) { + append(id, { + type: 'request/context', + data: { provider: selection.provider, model: selection.model, contextWindow: 128_000 }, + }) + } + startReply( + id, + turn, + userText === 'render markdown' + ? MARKDOWN_FIXTURE + : userText === 'report model' + ? (() => { + const selection = modelSelections.get(id) + return `当前模型:${selection?.provider ?? 'unknown'}/${selection?.model ?? 'unknown'}` + (selection?.reasoningEffort === undefined ? '' : ` · 推理等级:${selection.reasoningEffort}`) - })() - : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, - ) - return ok(request, { accepted: true as const }) + })() + : `回声:${userText}。这是 fixture 的流式回复,用于验证打字机增长与定稿切换。`, + ) + return sessionOk({ accepted: true as const }) + }, + attachment: (request) => { + const stored = attachments.get(String(request.attachmentId)) + if (stored === undefined) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture attachment missing', + details: { reason: 'ATTACHMENT_NOT_FOUND' }, + }) + } + if (!logReferencesAttachment( + logs.get(request.sessionId) ?? [], + String(request.attachmentId), + )) { + return sessionErr({ + code: 'attachment-error', + message: 'fixture attachment is not referenced by this session', + details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + }) + } + return sessionOk(stored) + }, + updateQueue: request => sessionErr({ + code: 'queue-item-not-found', + message: 'fixture has no pending queue item', + details: { itemId: request.itemId }, + }), + cancel: (request) => { + const replay = replays.get(request.sessionId) + if (replay !== undefined) { + clearTimeout(replay.timer) + replay.finish(true) + } else { + setRunning(request.sessionId, false) + } + return sessionOk({ accepted: true as const }) + }, + } + + const controlBaseline = (): Extract => { + const queues: Record = {} + const jobs: Record = {} + const projections: Record = {} + for (const summary of sessions) { + queues[summary.sessionId] = [] + jobs[summary.sessionId] = [] + const log = logs.get(summary.sessionId) ?? [] + projections[summary.sessionId] = { + asOfSeq: log.length - 1, + values: projectionValuesOf(log), + } + } + return { + type: 'baseline', + value: { + queues, + jobs, + approvals: [], + questions: [], + projections, }, - attachment: (request) => { - const stored = attachments.get(String(request.payload.attachmentId)) - if (stored === undefined) { - return err(request, { - code: 'attachment-error', - message: 'fixture attachment missing', - details: { reason: 'ATTACHMENT_NOT_FOUND' }, + } + } + + const approvalInvocation = (): FixtureRemoteEventInvocationFrame => ({ + type: 'waterfall', + event: 'approval/request', + eventId: pendingApprovalEventId, + agentId: sid('fx-alpha'), + request: { + toolName: 'dangerous_tool', + reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', + }, + }) + + const questionInvocation = (): FixtureRemoteEventInvocationFrame => ({ + type: 'waterfall', + event: 'user-questions/request', + eventId: pendingQuestionEventId, + agentId: sid('fx-alpha'), + request: { + questions: fixtureQuestions, + }, + }) + + async function* openControl(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + controlConns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + try { + yield controlBaseline() + yield* conn.drain(signal) + } finally { + streamBreakers.delete(breakNow) + controlConns.delete(conn) + } + } + + async function* openWorkspace(signal: AbortSignal): AsyncGenerator { + signal.throwIfAborted() + const conn = new FxInbox() + workspaceConns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + try { + yield workspaceBaseline() + yield* conn.drain(signal) + } finally { + streamBreakers.delete(breakNow) + workspaceConns.delete(conn) + } + } + + async function* openRemoteEvents( + signal: AbortSignal, + ): AsyncGenerator { + signal.throwIfAborted() + const clientId = randomUuid() + const conn = new FxInbox() + remoteEventConns.set(clientId, conn) + // Periodic material for the RPC-panel acceptance: flip fx-gamma every 5s. + // fx-gamma only; the conversation replay owns fx-alpha's running state. + const timer = setInterval(() => { + const gamma = summaryOf(sid('fx-gamma')) + /* v8 ignore next -- the fixture never removes fx-gamma. */ + if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) + }, 5000) + try { + yield { type: 'ready', clientId } + if (approvalPending) yield approvalInvocation() + if (questionPending) yield questionInvocation() + yield* conn.drain(signal) + } finally { + clearInterval(timer) + remoteEventConns.delete(clientId) + } + } + + async function* openFollow( + request: FixtureFollowRequest, + signal: AbortSignal, + ): AsyncGenerator { + signal.throwIfAborted() + const sessionId = request.address.kind === 'session' + ? request.address.sessionId + : request.address.childSessionId + if (summaryOf(sessionId) === undefined) throw new Error(`fixture: no session ${sessionId}`) + const conn = new FxInbox() + let conns = followConns.get(sessionId) + if (conns === undefined) { + conns = new Set() + followConns.set(sessionId, conns) + } + conns.add(conn) + const breakNow = (): void => { conn.breakNow() } + streamBreakers.add(breakNow) + const snapshot = [...logOf(sessionId)] + const cursor = snapshot.at(-1)?.seq ?? -1 + const summary = summaryOf(sessionId) + /* v8 ignore next -- existence was checked before the stream registered. */ + if (summary === undefined) throw new Error(`fixture: no session ${sessionId}`) + const initial = pageOf(snapshot, undefined, request.maxMessages ?? 50) + let nextSeq = cursor + 1 + try { + yield { + type: 'snapshot', + header: { + version: 0, + id: sessionId, + createdAt: summary.updatedAt, + ...(summary.cwd === undefined ? {} : { cwd: summary.cwd }), + ...(summary.parentSessionId === undefined ? {} : { parentSession: summary.parentSessionId }), + ...(summary.origin === undefined ? {} : { origin: summary.origin }), + ...(summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset }), + }, + cursor, + records: initial.records, + hasMore: initial.hasMore, + projections: { asOfSeq: cursor, values: projectionValuesOf(snapshot) }, + } + for await (const frame of conn.drain(signal)) { + if (frame.event.seq < nextSeq) continue + if (frame.event.seq !== nextSeq) { + throw new Error(`fixture: session event stream skipped seq ${String(nextSeq)}`) + } + nextSeq++ + yield frame + } + } finally { + streamBreakers.delete(breakNow) + conns.delete(conn) + if (conns.size === 0) followConns.delete(sessionId) + } + } + + const answerRemoteEvent = (result: FixtureRemoteEventResult): ConnectionRpcResult => { + if (!remoteEventConns.has(result.clientId)) { + return { + ok: false, + error: { + code: 'invocation-unavailable', + message: 'fixture Remote event result identifies no active event stream', + details: {}, + }, + } + } + if (result.eventId === pendingApprovalEventId) { + if (!approvalPending) return { ok: true, value: undefined } + approvalPending = false + } else if (result.eventId === pendingQuestionEventId) { + if (!questionPending) return { ok: true, value: undefined } + questionPending = false + } else { + return { ok: true, value: undefined } + } + emitRemoteFrame({ type: 'cancel', eventId: result.eventId }) + return { ok: true, value: undefined } + } + + const workspaceApi: FixtureWorkspaceApi = { + create: (request) => { + const existing = workspaces.find(workspace => workspace.path === request.path) + if (existing !== undefined) { + return sessionOk({ workspace: workspaceSnapshot(existing), created: false }) + } + const now = new Date().toISOString() + const created: FixtureWorkspace = { + workspaceId: wid(`fx-ws-${nextWorkspace++}`), + path: request.path, + title: request.path.split('/').filter(Boolean).at(-1) ?? request.path, + sessionIds: [], + createdAt: now, + updatedAt: now, + } + workspaces.unshift(created) + const workspace = workspaceSnapshot(created) + emitWorkspace({ type: 'upsert', workspace }) + return sessionOk({ workspace, created: true }) + }, + rename: (request) => { + const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) + if (workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + const title = request.title.trim() + if (title === '') { + return sessionErr({ + code: 'bad-request', + message: 'Workspace rename requires a non-blank title', + details: {}, + }) + } + if (title !== workspace.title) { + if (workspaces.some(candidate => candidate.workspaceId !== request.workspaceId && candidate.title === title)) { + return sessionErr({ + code: 'workspace-name-conflict', + message: `workspace name '${title}' is already in use`, + details: { name: title }, }) } - if (!logReferencesAttachment( - logs.get(request.payload.sessionId) ?? [], - String(request.payload.attachmentId), - )) { - return err(request, { - code: 'attachment-error', - message: 'fixture attachment is not referenced by this session', - details: { reason: 'ATTACHMENT_NOT_REFERENCED' }, + workspace.title = title + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ workspace: workspaceSnapshot(workspace) }) + }, + delete: (request) => { + const index = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) + if (index === -1) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + workspaces.splice(index, 1) + emitWorkspace({ type: 'remove', workspaceId: request.workspaceId }) + return sessionOk({ deleted: true }) + }, + insertBefore: (request) => { + const source = workspaces.findIndex(workspace => workspace.workspaceId === request.workspaceId) + const anchor = request.beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(workspace => workspace.workspaceId === request.beforeWorkspaceId) + const missing = source === -1 + ? request.workspaceId + : anchor === -1 + ? request.beforeWorkspaceId + : undefined + if (missing !== undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${missing}`, + details: { workspaceId: missing }, + }) + } + if (request.beforeWorkspaceId !== request.workspaceId) { + const previousOrder = workspaces.map(workspace => workspace.workspaceId) + const [workspace] = workspaces.splice(source, 1) + /* v8 ignore next -- source was resolved from the same array immediately above. */ + if (workspace === undefined) throw new Error(`fixture lost workspace ${request.workspaceId}`) + const at = request.beforeWorkspaceId === undefined + ? workspaces.length + : workspaces.findIndex(candidate => candidate.workspaceId === request.beforeWorkspaceId) + workspaces.splice(at, 0, workspace) + if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { + emitWorkspace({ + type: 'order', + workspaceIds: workspaces.map(candidate => candidate.workspaceId), }) } - return ok(request, stored) - }, - updateQueue: request => err(request, { - code: 'queue-item-not-found', - message: 'fixture has no pending queue item', - details: { itemId: request.payload.itemId }, - }), - cancel: (request) => { - const replay = replays.get(request.payload.sessionId) - if (replay !== undefined) { - clearTimeout(replay.timer) - replay.finish(true) - } else { - setRunning(request.payload.sessionId, false) - } - return ok(request, { accepted: true as const }) - }, + } + return sessionOk({ workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) }, - subagents: { - list: request => ok(request, { entries: [], parentAvailable: true }), - history: (request) => { - const log = logs.get(request.payload.childSessionId) ?? [] - return Promise.resolve(ok( - request, - pageOf(log, request.payload.beforeSeq, request.payload.maxMessages ?? 50), - )) - }, - prompt: request => Promise.resolve(ok(request, { - messageId: `fixture-message-${request.payload.childSessionId}` as never, - })), - interrupt: request => Promise.resolve(ok(request, { accepted: true as const })), + insertSessionBefore: (request) => { + const workspace = workspaces.find(candidate => candidate.workspaceId === request.workspaceId) + if (workspace === undefined) { + return sessionErr({ + code: 'workspace-not-found', + message: `no workspace ${request.workspaceId}`, + details: { workspaceId: request.workspaceId }, + }) + } + if (!workspace.sessionIds.includes(request.sessionId) + || (request.beforeSessionId !== undefined && !workspace.sessionIds.includes(request.beforeSessionId))) { + return sessionErr({ + code: 'workspace-move-invalid', + message: `session or anchor is not accounted by workspace ${request.workspaceId}`, + details: { + workspaceId: request.workspaceId, + sessionId: request.sessionId, + ...request.beforeSessionId === undefined ? {} : { beforeSessionId: request.beforeSessionId }, + }, + }) + } + const without = workspace.sessionIds.filter(id => id !== request.sessionId) + const at = request.beforeSessionId === undefined ? without.length : without.indexOf(request.beforeSessionId) + const sessionIds = [...without.slice(0, at), request.sessionId, ...without.slice(at)] + if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { + workspace.sessionIds = sessionIds + workspace.updatedAt = new Date().toISOString() + emitWorkspace({ type: 'upsert', workspace: workspaceSnapshot(workspace) }) + } + return sessionOk({ workspace: workspaceSnapshot(workspace) }) }, + archiveSession: (request) => { + if (summaryOf(request.sessionId) === undefined) { + return sessionErr({ + code: 'session-not-found', + message: `no session ${request.sessionId}`, + details: { sessionId: request.sessionId }, + }) + } + if (!archivedSessionIds.includes(request.sessionId)) { + archivedSessionIds.push(request.sessionId) + emitWorkspace({ type: 'archived', archivedSessionIds: [...archivedSessionIds] }) + } + return sessionOk({ archivedSessionIds: [...archivedSessionIds] }) + }, + } + + const api: ApiProxy = { host: { describe: request => ok(request, { version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true, @@ -2612,190 +3302,7 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }, openPath: request => ok(request, { opened: true as const }), }, - workspace: { - list: request => ok(request, { - items: workspaces.map(w => ({ ...w })), - archivedSessionIds: [...archivedSessionIds], - }), - create: (request) => { - const { path } = request.payload - const existing = workspaces.find(w => w.path === path) - if (existing !== undefined) return ok(request, { workspace: { ...existing }, created: false }) - const now = new Date().toISOString() - const created: WorkspaceView = { - workspaceId: wid(`fx-ws-${nextWorkspace++}`), - path, - title: path.split('/').filter(Boolean).at(-1) ?? path, - sessionIds: [], - createdAt: now, - updatedAt: now, - } - workspaces.unshift(created) - emitHost({ type: 'host/workspace-changed', workspace: { ...created } }) - return ok(request, { workspace: { ...created }, created: true }) - }, - rename: (request) => { - const { workspaceId, title } = request.payload - const workspace = workspaces.find(w => w.workspaceId === workspaceId) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - const trimmed = title.trim() - if (trimmed !== workspace.title) { - if (workspaces.some(w => w.workspaceId !== workspaceId && w.title === trimmed)) { - return err(request, { - code: 'workspace-name-conflict', - message: `workspace name '${trimmed}' is already in use`, - details: { name: trimmed }, - }) - } - workspace.title = trimmed - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - 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 }) - }, - insertBefore: (request) => { - const { workspaceId, beforeWorkspaceId } = request.payload - const source = workspaces.findIndex(workspace => workspace.workspaceId === workspaceId) - const anchor = beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(workspace => workspace.workspaceId === beforeWorkspaceId) - const missing = source === -1 ? workspaceId : anchor === -1 ? beforeWorkspaceId : undefined - if (missing !== undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${missing}`, - details: { workspaceId: missing }, - }) - } - if (beforeWorkspaceId !== workspaceId) { - const previousOrder = workspaces.map(candidate => candidate.workspaceId) - const [workspace] = workspaces.splice(source, 1) - /* v8 ignore next -- source was resolved from the same array immediately above. */ - if (workspace === undefined) throw new Error(`fixture lost workspace ${workspaceId}`) - const at = beforeWorkspaceId === undefined - ? workspaces.length - : workspaces.findIndex(candidate => candidate.workspaceId === beforeWorkspaceId) - workspaces.splice(at, 0, workspace) - if (workspaces.some((candidate, index) => candidate.workspaceId !== previousOrder[index])) { - emitHost({ - type: 'host/workspace-order-changed', - workspaceIds: workspaces.map(candidate => candidate.workspaceId), - }) - } - } - return ok(request, { workspaceIds: workspaces.map(candidate => candidate.workspaceId) }) - }, - insertSessionBefore: (request) => { - const { workspaceId, sessionId, beforeSessionId } = request.payload - const workspace = workspaces.find(w => w.workspaceId === workspaceId) - if (workspace === undefined) { - return err(request, { - code: 'workspace-not-found', - message: `no workspace ${workspaceId}`, - details: { workspaceId }, - }) - } - if (!workspace.sessionIds.includes(sessionId) - || (beforeSessionId !== undefined && !workspace.sessionIds.includes(beforeSessionId))) { - return err(request, { - code: 'workspace-move-invalid', - message: `session or anchor is not accounted by workspace ${workspaceId}`, - details: { workspaceId, sessionId, ...beforeSessionId === undefined ? {} : { beforeSessionId } }, - }) - } - const without = workspace.sessionIds.filter(id => id !== sessionId) - const at = beforeSessionId === undefined ? without.length : without.indexOf(beforeSessionId) - const sessionIds = [...without.slice(0, at), sessionId, ...without.slice(at)] - if (!sessionIds.every((id, index) => id === workspace.sessionIds[index])) { - workspace.sessionIds = sessionIds - workspace.updatedAt = new Date().toISOString() - emitHost({ type: 'host/workspace-changed', workspace: { ...workspace } }) - } - return ok(request, { workspace: { ...workspace } }) - }, - archiveSession: (request) => { - const missing = requireSession(request) - if (missing !== undefined) return missing - const { sessionId } = request.payload - if (!archivedSessionIds.includes(sessionId)) { - archivedSessionIds.push(sessionId) - emitHost({ type: 'host/archived-sessions-changed', archivedSessionIds: [...archivedSessionIds] }) - } - return ok(request, { archivedSessionIds: [...archivedSessionIds] }) - }, - }, agentPresets: { - // Both trusts appear, because a surface must present a locally authored - // preset differently from one the deployment vetted. - list: request => ok(request, { - presets: [...fixturePresets].map(([id, preset]) => ({ - id, - trust: preset.trust, - isDefault: id === fixtureDefaultPreset, - })), - authorable: true, - hasDocument: true, - }), - select: (request) => { - fixtureDefaultPreset = request.payload.agentPreset - return ok(request, { agentPreset: request.payload.agentPreset }) - }, - read: (request) => { - const { agentPreset } = request.payload - const preset = fixturePresets.get(agentPreset) - if (preset === undefined) { - return err(request, { - code: 'agent-preset-not-found', - message: `unknown agent preset "${agentPreset}"`, - details: { agentPreset, available: [...fixturePresets.keys()] }, - }) - } - return ok(request, { - agentPreset, - trust: preset.trust, - content: preset.content, - }) - }, - copy: (request) => { - const { from, agentPreset } = request.payload - const source = fixturePresets.get(from) - if (source === undefined) { - return err(request, { - code: 'agent-preset-not-found', - message: `unknown agent preset "${from}"`, - details: { agentPreset: from, available: [...fixturePresets.keys()] }, - }) - } - if (fixturePresets.has(agentPreset)) { - return err(request, { - code: 'agent-preset-invalid', - message: `agent preset "${agentPreset}" already exists`, - details: { agentPreset, reason: 'already exists' }, - }) - } - fixturePresets.set(agentPreset, { trust: 'user', content: source.content }) - return ok(request, { agentPreset }) - }, // Native opens are deterministic no-op successes in this fixture, so the // open-directory affordance renders and the path-text fallback stays a // component-test concern. @@ -2811,19 +3318,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } return ok(request, { opened: true as const }) }, - remove: (request) => { - const { agentPreset } = request.payload - const existing = fixturePresets.get(agentPreset) - if (existing?.trust === 'system') { - return err(request, { - code: 'agent-preset-read-only', - message: `agent preset "${agentPreset}" ships with the deployment`, - details: { agentPreset, reason: 'it ships with the deployment' }, - }) - } - fixturePresets.delete(agentPreset) - return ok(request, {}) - }, }, skills: { @@ -2838,109 +3332,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) }, }, - goals: { - // Compatibility face only: old API Proxy payloads and acknowledgements - // adapt to the canonical fixture Remote implementation above. - create: request => legacyGoalResponse( - request, - mapGoalResult( - goalRemotes.create(request.payload.sessionId, { - objective: request.payload.objective, - ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, - }), - value => ({ ref: { id: value.ref.id as never, revision: value.ref.revision } }), - ), - ), - edit: request => legacyGoalResponse( - request, - goalRefResult(goalRemotes.edit(request.payload.sessionId, request.payload.ref, { - ...request.payload.objective === undefined ? {} : { objective: request.payload.objective }, - ...request.payload.maxGoalRounds === undefined ? {} : { maxGoalRounds: request.payload.maxGoalRounds }, - })), - ), - pause: request => legacyGoalResponse( - request, - goalRefResult(goalRemotes.pause(request.payload.sessionId, request.payload.ref)), - ), - resume: request => legacyGoalResponse( - request, - goalRefResult(goalRemotes.resume(request.payload.sessionId, request.payload.ref)), - ), - complete: request => legacyGoalResponse( - request, - goalRefResult(goalRemotes.complete(request.payload.sessionId, request.payload.ref)), - ), - clear: request => legacyGoalResponse( - request, - mapGoalResult( - goalRemotes.clear(request.payload.sessionId, request.payload.ref), - () => ({ cleared: true as const }), - ), - ), - }, - events: { - async *mux(_request, signal) { - const conn = new FxInbox() - muxConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Open baseline: subscribed sessions + pending interactions replayed with stable rpcIds. - for (const s of sessions) { - if (!s.running) continue - const log = logs.get(s.sessionId) ?? [] - conn.push({ rpcId: mint(), payload: { type: 'session/subscribed', sessionId: s.sessionId, lastSeq: log.length - 1 } }) - // Post-subscribe projection baseline (host parallel: recomputed unit values ride push frames). - const values = projectionValuesOf(log) - for (const key of Object.keys(values)) { - conn.push({ rpcId: mint(), payload: { type: 'session/projection', sessionId: s.sessionId, key, value: values[key], seq: log.length - 1 } }) - } - } - if (approvalPending) { - conn.push({ - rpcId: pendingApprovalRpcId, - payload: { - type: 'approval/requested', sessionId: sid('fx-alpha'), - approvalId: pendingApprovalId, - toolName: 'dangerous_tool', reason: 'fixture 常驻审批(可答:批准/拒绝后消失)', - }, - }) - } - if (questionPending) { - conn.push({ - rpcId: pendingQuestionRpcId, - payload: { - type: 'question/requested', sessionId: sid('fx-alpha'), questions: fixtureQuestions, - }, - }) - } - try { - yield* conn.drain(signal) - } finally { - streamBreakers.delete(breakNow) - muxConns.delete(conn) - } - }, - async *host(_request, signal) { - const conn = new FxInbox() - hostConns.add(conn) - const breakNow = (): void => { conn.breakNow() } - streamBreakers.add(breakNow) - // Periodic material (the RPC-panel acceptance's clear-then-new-frames step depends on it): flip fx-gamma every 5s. - // fx-gamma only: never touch fx-alpha's running semantics (the conversation replay drives that). - const timer = setInterval(() => { - const gamma = summaryOf(sid('fx-gamma')) - /* v8 ignore next -- the undefined arm needs fx-gamma deleted, but the fixture never removes sessions. */ - if (gamma !== undefined) setRunning(gamma.sessionId, !gamma.running) - }, 5000) - try { - yield* conn.drain(signal) - } finally { - clearInterval(timer) - streamBreakers.delete(breakNow) - hostConns.delete(conn) - } - }, - }, settings: { // Only the resolved DeepSeek address needed by first-run readiness is // represented here. Fixture-backed journeys do not open its Models @@ -3003,7 +3394,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { { provider: 'acme-gateway', displayName: 'Acme Gateway', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'acme-gateway'], active: true, declared: true }, ], }), - models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }), + models: request => ok(request, { + default: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + routableProviders: ['deepseek-official', 'openai', 'acme-gateway'], + groups: fixtureModelGroups(), + failures: [], + }), // The fixture endpoint is imaginary, so the interrogation answers the // catalog it already serves — enough for a surface to exercise adopting // candidates without a reachable provider. @@ -3011,31 +3407,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { models: fixtureModelGroups().flatMap(group => group.models.map(model => ({ id: model.id, name: model.name }))), }), }, - respond(message: ClientResponse): Promise { - // Same routing discipline as the host: rpcId first, then the payload's - // audit correlation; a settled or unknown id is not-pending. - if (message.rpcId === pendingApprovalRpcId) { - if (!approvalPending) return Promise.resolve({ accepted: false, reason: 'not-pending' }) - if (!message.result.ok) return Promise.resolve({ accepted: false, reason: 'bad-response' }) - const value = message.result.value as { approvalId?: unknown; outcome?: unknown } - if (value.approvalId !== pendingApprovalId || (value.outcome !== 'allowed-once' && value.outcome !== 'rejected')) { - return Promise.resolve({ accepted: false, reason: 'bad-response' }) - } - approvalPending = false - emitMux({ type: 'approval/resolved', sessionId: sid('fx-alpha'), approvalId: pendingApprovalId, outcome: value.outcome }) - return Promise.resolve({ accepted: true }) - } - if (!questionPending || message.rpcId !== pendingQuestionRpcId) { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) - } - questionPending = false - emitMux({ - type: 'question/resolved', sessionId: sid('fx-alpha'), - questionRpcId: pendingQuestionRpcId, - outcome: message.result.ok ? 'answered' : 'cancelled', - }) - return Promise.resolve({ accepted: true }) - }, // Satisfies the ApiProxy contract type only: the browser export button // hands GET /api/session.export to the native download manager, so this // stub is never reached through the fixture's dispatch. @@ -3045,45 +3416,142 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { } const rpc: ClientConnectionRpc = { - call(channel, endpoint, payload) { + call(channel, endpoint, payload, signal) { if (channel !== '/api') { return Promise.reject(new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`)) } const args = (payload as { - args: { + args: Readonly<{ agentId: SessionId line?: string + query?: string images?: readonly unknown[] ref?: { id: string; revision: number } - request?: { objective?: string; maxGoalRounds?: number } - } + agentPreset?: string + from?: string + id?: string + request?: unknown + _request?: unknown + }> }).args const sessionId = args.agentId + const callSignal = signal ?? new AbortController().signal + const request = args.request switch (endpoint) { case 'commands/list': return Promise.resolve(commandRemotes.list(sessionId)) case 'commands/execute': return Promise.resolve(commandRemotes.execute(sessionId, args.line as string, args.images ?? [])) + case 'fileReferences/list': return Promise.resolve(referenceRemotes.files(sessionId, args.query ?? '')) + case 'sessionReferenceResolver/candidates': return Promise.resolve(referenceRemotes.sessions(sessionId, args.query ?? '')) case 'goals/create': return Promise.resolve(goalRemotes.create(sessionId, { - objective: args.request?.objective as string, - ...args.request?.maxGoalRounds === undefined ? {} : { maxGoalRounds: args.request.maxGoalRounds }, + objective: (request as { objective?: string } | undefined)?.objective as string, + ...(request as { maxGoalRounds?: number } | undefined)?.maxGoalRounds === undefined + ? {} + : { maxGoalRounds: (request as { maxGoalRounds: number }).maxGoalRounds }, })) - case 'goals/edit': return Promise.resolve(goalRemotes.edit(sessionId, args.ref as FxGoalRef, args.request ?? {})) + case 'goals/edit': return Promise.resolve(goalRemotes.edit( + sessionId, + args.ref as FxGoalRef, + request as { objective?: string; maxGoalRounds?: number }, + )) case 'goals/pause': return Promise.resolve(goalRemotes.pause(sessionId, args.ref as FxGoalRef)) case 'goals/resume': return Promise.resolve(goalRemotes.resume(sessionId, args.ref as FxGoalRef)) case 'goals/complete': return Promise.resolve(goalRemotes.complete(sessionId, args.ref as FxGoalRef)) case 'goals/clear': return Promise.resolve(goalRemotes.clear(sessionId, args.ref as FxGoalRef)) + case 'agentPresets/list': return Promise.resolve(presetRemotes.list()) + case 'agentPresets/select': return Promise.resolve(presetRemotes.select(sessionId, args.agentPreset as string)) + case 'agentPresets/read': return Promise.resolve(presetRemotes.read(args.agentPreset as string)) + case 'agentPresets/copy': return Promise.resolve(presetRemotes.copy(args.from as string, args.id as string)) + case 'agentPresets/deletePreset': return Promise.resolve(presetRemotes.deletePreset(args.id as string)) + case 'subagents/list': return Promise.resolve({ + ok: true, + value: { entries: [], parentAvailable: true }, + }) + case 'subagents/prompt': return Promise.resolve({ + ok: true, + value: { + messageId: `fixture-message-${(request as { childSessionId: SessionId }).childSessionId}`, + }, + }) + case 'subagents/interruptByParent': return Promise.resolve({ ok: true, value: { accepted: true } }) + case 'session/list': return sessionApi.list( + args._request as Parameters[0], + ) + case 'session/search': return sessionApi.search( + request as Parameters[0], + callSignal, + ) + case 'session/create': return sessionApi.create( + request as Parameters[0], + ) + case 'session/selectModel': return sessionApi.selectModel( + request as Parameters[0], + ) + case 'session/rename': return sessionApi.rename( + request as Parameters[0], + ) + case 'session/fork': return sessionApi.fork( + request as Parameters[0], + ) + case 'session/prompt': return sessionApi.prompt( + request as Parameters[0], + ) + case 'session/attachment': return sessionApi.attachment( + request as Parameters[0], + ) + case 'session/updateQueue': return sessionApi.updateQueue( + request as Parameters[0], + ) + case 'session/cancel': return sessionApi.cancel( + request as Parameters[0], + ) + case 'session/page': { + const page = request as FixturePageRequest + const pageSessionId = page.address.kind === 'session' + ? page.address.sessionId + : page.address.childSessionId + return sessionApi.history({ + sessionId: pageSessionId, + throughSeq: page.throughSeq, + ...page.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, + ...page.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, + }) + } + case '$events/result': return Promise.resolve(answerRemoteEvent(args as unknown as FixtureRemoteEventResult)) + case 'workspace/create': return workspaceApi.create(request as WorkspaceCreateRequest) + case 'workspace/rename': return workspaceApi.rename(request as WorkspaceRenameRequest) + case 'workspace/delete': return workspaceApi.delete(request as WorkspaceDeleteRequest) + case 'workspace/insertBefore': return workspaceApi.insertBefore(request as WorkspaceInsertBeforeRequest) + case 'workspace/insertSessionBefore': return workspaceApi.insertSessionBefore( + request as WorkspaceInsertSessionBeforeRequest, + ) + case 'workspace/archiveSession': return workspaceApi.archiveSession(request as WorkspaceArchiveSessionRequest) default: return Promise.reject(new Error(`fixture connection RPC endpoint ${JSON.stringify(endpoint)} is unavailable`)) } }, + open(channel, endpoint, payload, signal) { + if (channel !== '/api') { + throw new Error(`fixture connection RPC channel ${JSON.stringify(channel)} is unavailable`) + } + const args = (payload as { args: Readonly<{ request?: unknown }> }).args + switch (endpoint) { + case '$events': return openRemoteEvents(signal) + case 'session/control': return openControl(signal) + case 'session/follow': return openFollow(args.request as FixtureFollowRequest, signal) + case 'workspace/follow': return openWorkspace(signal) + default: + throw new Error(`fixture connection stream endpoint ${JSON.stringify(endpoint)} is unavailable`) + } + }, } return { api, rpc } } /** * Fixture platform subclass: there is no HTTP at all, so instead of a doFetch transport it - * overrides the protocol-level virtuals (callUnary/openMux/openHost/respond) to dispatch - * straight into the in-memory ApiProxy — while still minting rpcIds, fabricating the four - * named full forms, and feeding the same tap as a real carrier. TODO: delete when the fixture + * overrides the legacy protocol-level call virtual to dispatch + * straight into the in-memory ApiProxy while still minting rpcIds, fabricating + * the request/response envelopes, and feeding the same tap as a real carrier. TODO: delete when the fixture * moves to the isomorphic pipeline (InProcessApiClient over toFetchHandler(fixtureImpl)). */ export class FixtureApiClient extends AbstractApiClient { @@ -3127,47 +3595,13 @@ export class FixtureApiClient extends AbstractApiClient { signal: AbortSignal, ): Promise> { switch (method) { - case 'session.list': return this.api.sessions.list(request) - case 'session.search': return this.api.sessions.search(request, signal) - 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.rename': return this.api.sessions.rename(request) - case 'session.fork': return this.api.sessions.fork(request) - case 'session.prompt': return this.api.sessions.prompt(request) - case 'session.attachment': return this.api.sessions.attachment(request) - case 'session.updateQueue': return this.api.sessions.updateQueue(request) - case 'session.cancel': return this.api.sessions.cancel(request) - case 'subagent.list': return this.api.subagents.list(request) - case 'subagent.history': return this.api.subagents.history(request) - case 'subagent.prompt': return this.api.subagents.prompt(request, signal) - case 'subagent.interrupt': return this.api.subagents.interrupt(request) case 'host.describe': return this.api.host.describe(request) case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal) case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal) case 'host.createDirectory': return this.api.host.createDirectory(request) case 'host.openPath': return this.api.host.openPath(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.insertBefore': return this.api.workspace.insertBefore(request) - case 'workspace.insertSessionBefore': return this.api.workspace.insertSessionBefore(request) - case 'workspace.archiveSession': return this.api.workspace.archiveSession(request) case 'skill.list': return this.api.skills.list(request) - case 'agentPreset.list': return this.api.agentPresets.list(request) - case 'agentPreset.select': return this.api.agentPresets.select(request) - case 'agentPreset.read': return this.api.agentPresets.read(request) - case 'agentPreset.copy': return this.api.agentPresets.copy(request) case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal) - case 'agentPreset.remove': return this.api.agentPresets.remove(request) - case 'goal.create': return this.api.goals.create(request) - case 'goal.edit': return this.api.goals.edit(request) - case 'goal.pause': return this.api.goals.pause(request) - case 'goal.resume': return this.api.goals.resume(request) - case 'goal.complete': return this.api.goals.complete(request) - case 'goal.clear': return this.api.goals.clear(request) case 'settings.describe': return this.api.settings.describe(request) case 'settings.openDocument': return this.api.settings.openDocument(request, signal) case 'settings.update': return this.api.settings.update(request) @@ -3182,46 +3616,6 @@ export class FixtureApiClient extends AbstractApiClient { } } - protected override openMux( - payload: { since?: Record }, - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.tapStream(this.api.events.mux(rpcRequest(payload), signal), onOpen) - } - - protected override openHost( - payload: Record, - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.tapStream(this.api.events.host(rpcRequest(payload), signal), onOpen) - } - - private async *tapStream( - stream: AsyncIterable>, - onOpen?: () => void, - ): AsyncGenerator> { - // No HTTP here: the in-memory stream is established the moment iteration starts (mirrors - // readSse firing onOpen after response headers, before any frame). - onOpen?.() - for await (const envelope of stream) { - const full: ServerRequest = { type: 'server-request', rpcId: envelope.rpcId, method: envelope.payload.type, payload: envelope.payload } - this.onEnvelope(full) - yield envelope - } - } - - /** - * Deliver a client response to the in-memory contract impl (no HTTP POST), - * echoing the envelope to the observation tap like every other path. - * @param message - the client-response envelope answering a server request. - * @returns the carrier receipt from the fixture impl. - */ - override async respond(message: ClientResponse): Promise { - this.onEnvelope(message) - return this.api.respond(message) - } } /** Browser query mapping; direct unit callers pass FixtureOptions explicitly. */ diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 9847d48cdf..358b2f41cc 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -1,32 +1,43 @@ /** * Browser wire client. The plugin selects fixture or HTTP transport, provides - * the shared API client, and lets the runtime object layer start the stream - * controller with its sinks. + * the shared API client, and lets API Gateway own the connection loop. */ import type { Context } from '@deepseek-ai/cordis' import type { HostDescription, IApiClient } from './api.ts' -import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' +import { + ConnectionController, + type ConnectionConfig, + type ConnectionGenerationSource, + type ConnectionSinks, + type ConnectionState, +} from './connection.ts' import { FixtureApiClient } from './fixture.ts' import { WebApiClient } from './web-api-client.ts' -import { createWebConnectionRpc } from './rpc.ts' +import { createWebConnectionRpc, type RpcFetch, type RpcStreamOpen } from './rpc.ts' import { isLoopbackHostname } from '../loopback-hostname.ts' import type { ClientConnectionRpc } from '../rpc.ts' +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * A connection generation was established. Wire-derived caches must + * repull; long-lived streams own their own resume and baseline lifecycle. + * @mode emit + */ + 'connection/reset'(): void + } +} + // ---- Contract re-exports (browser-safe apiproxy channels + core types) ---- export type { - ApiProxy, SessionsApi, SessionSearchItem, SessionSummary, PromptContentPart, HostApi, EventsApi, MuxFrame, HostFrame, - ApprovalResponsePayload, QuestionResponsePayload, HistoryEntry, ToolEventView, + ApiProxy, HostApi, DirectoryEntry, DirectoryListing, - ToolCallView, ToolResultView, WorkspaceApi, WorkspaceId, WorkspaceView, SkillsApi, SkillEntry, - ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, - SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, - JobView, + ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, + MessageId, ModelReasoningEffort, ModelSelection, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, - ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, + ClientRequest, ServerResponse, RpcMessage, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, - GoalsApi, GoalRef, SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, } from './api.ts' @@ -38,8 +49,11 @@ export { // Connection loop types are public through ConnectionHandle.start; the // controller remains package-internal. -export type { ConnectionConfig, ConnectionSinks, ConnectionState } -export type { ClientConnectionRpc } from '../rpc.ts' +export type { ConnectionConfig, ConnectionGenerationSource, ConnectionSinks, ConnectionState } +export type { + ClientConnectionRpc, ConnectionRpcFailure, ConnectionRpcResult, +} from '../rpc.ts' +export type { RpcFetch } from './rpc.ts' /** Observable Host description published by each completed connection handshake. */ export interface HostDescriptionSource { @@ -53,30 +67,81 @@ export interface HostDescriptionSource { export const inject: string[] = [] /** - * The ctx.connection service API: the API client plus a one-shot - * controller starter (the runtime plugin supplies sinks when its object layer - * is ready — connection stays consumer-agnostic). + * Carrier override installed on the page global before plugin boot. The served + * web app leaves it unset and gets HTTP + WebSocket; a shell that owns a + * different physical transport (the worker preview's postMessage tunnel) + * provides both halves here instead of forking this plugin. + */ +export interface ClientTransportHooks { + /** Build the API carrier: unary calls plus the two downstream event streams. */ + createApiClient(): IApiClient + /** Transport for generic unary RPC channels (the Typert gateway). */ + fetch: RpcFetch + /** Worker-local Gateway stream carrier; absent when the page uses the Gateway WebSocket. */ + openStream?: RpcStreamOpen + /** + * Bundle transport for the module system, present when the carrier also owns + * bundle bytes (the worker tunnel). Absent in the served web app, whose + * bundles load over HTTP. + */ + loadBundle?(url: string): Promise + /** + * The transport owner declares the page owns the Host outright: the Host + * runs inside a worker this page spawned, so no other party can reach it and + * the loopback stand-in for "the operator's own machine" is vacuous. + * `ctx.connection.isLoopback` then reports the privileged surface reachable + * regardless of the page authority. Only a shell that assembles its own + * transport can set this; served pages never carry the global at all. + */ + ownsHost?: boolean +} + +/** Page global carrying {@link ClientTransportHooks}; absent in the served web app. */ +interface ClientTransportGlobal { + __DSH_TRANSPORT__?: ClientTransportHooks +} + +/** + * The ctx.connection service API: the API client plus a one-shot controller + * starter. API Gateway supplies generation readiness and reset callbacks; + * Connection stays independent of downstream domain state. */ export interface ConnectionHandle { /** Shared api client (fixture or real, decided at boot from the page URL). */ readonly api: IApiClient - /** Whether the current page authority is loopback; non-browser contexts default to true. */ + /** + * Whether the privileged surface is reachable: the page authority is + * loopback, the transport declares the page owns the Host + * ({@link ClientTransportHooks.ownsHost}), or the context is not a browser. + */ readonly isLoopback: boolean /** Generation-scoped Host facts, including the account home and native path-open capability. */ readonly hostDescription: HostDescriptionSource /** Generic logical RPC channels over the same Connection transport. */ readonly rpc: ClientConnectionRpc /** - * Start the connect/pump/reconnect loop with the consumer's frame sinks. - * One consumer owns the streams (the runtime object layer); a second call - * throws. - * @param sinks - frame/state callbacks. + * Register the sole source defining Host generations. The source reports + * ready only after its incremental listeners are attached. + * @param source - long-lived generation source owned by the push carrier. + * @returns disposer withdrawing the source and stopping an active loop. + */ + registerGenerationSource(source: ConnectionGenerationSource): () => void + /** + * Start the connect/reconnect loop with the consumer's state callbacks. + * API Gateway owns the loop; a second call throws. + * @param sinks - connection-state callbacks. * @param config - reconnect/backoff tunables. * @returns stop handle for the loop. */ start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void } } +interface ConnectionOwner { + readonly token: object + readonly source: ConnectionGenerationSource + readonly controller: ConnectionController +} + /** * Client plugin body: pick the api by page mode and provide ctx.connection. * @param ctx - client cordis context. @@ -85,9 +150,11 @@ export function apply(ctx: Context): void { const pageLocation = typeof location === 'undefined' ? undefined : location const fixture = pageLocation !== undefined && new URLSearchParams(pageLocation.search).has('fixture') const fixtureClient = fixture ? new FixtureApiClient() : undefined - const api: IApiClient = fixtureClient ?? new WebApiClient() - const rpc = fixtureClient?.rpc ?? createWebConnectionRpc() - let started = false + const transport = (globalThis as ClientTransportGlobal).__DSH_TRANSPORT__ + const api: IApiClient = fixtureClient ?? transport?.createApiClient() ?? new WebApiClient() + const rpc = fixtureClient?.rpc ?? createWebConnectionRpc(transport?.fetch, transport?.openStream) + let generationSource: ConnectionGenerationSource | undefined + let owner: ConnectionOwner | undefined let description: HostDescription | undefined const descriptionListeners = new Set<() => void>() const publishDescription = (next: HostDescription | undefined): void => { @@ -97,13 +164,19 @@ export function apply(ctx: Context): void { try { listener() } catch (error) { - console.error('[web-runtime] host-description listener threw:', error) + console.error('[connection] host-description listener threw:', error) } } } + const releaseOwner = (current: ConnectionOwner): void => { + if (owner !== current) return + owner = undefined + current.controller.stop() + publishDescription(undefined) + } const handle: ConnectionHandle = { api, - isLoopback: pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), + isLoopback: transport?.ownsHost === true || pageLocation === undefined || isLoopbackHostname(pageLocation.hostname), hostDescription: { getSnapshot: () => description, subscribe: (listener) => { @@ -112,10 +185,25 @@ export function apply(ctx: Context): void { }, }, rpc, + registerGenerationSource(source) { + if (generationSource !== undefined) { + throw new Error('connection: a generation source is already registered') + } + generationSource = source + return () => { + if (generationSource !== source) return + generationSource = undefined + const current = owner + if (current?.source === source) releaseOwner(current) + } + }, start(sinks, config) { - if (started) throw new Error('connection: the stream loop is already owned by another consumer') - started = true - const controller = new ConnectionController(api, { + if (owner !== undefined) throw new Error('connection: the stream loop is already owned by another consumer') + const source = generationSource + if (source === undefined) throw new Error('connection: no generation source is registered') + const token = {} + const ownsGeneration = (): boolean => owner?.token === token + const controller = new ConnectionController(api, source, { ...sinks, onConnected: (next) => { publishDescription(next) @@ -123,20 +211,20 @@ export function apply(ctx: Context): void { // case publishDescription(undefined) has already retracted this // generation, so do not leak its stale connected notification to // the consumer sink afterward. - if (!Object.is(description, next)) return + if (!ownsGeneration() || !Object.is(description, next)) return sinks.onConnected?.(next) }, onStateChange: (state) => { if (state === 'reconnecting') publishDescription(undefined) + if (!ownsGeneration()) return sinks.onStateChange?.(state) }, }, config ?? {}) + const current = { token, source, controller } + owner = current controller.start() return { - stop: () => { - controller.stop() - publishDescription(undefined) - }, + stop: () => { releaseOwner(current) }, } }, } diff --git a/packages/client/connection/src/client/rpc.ts b/packages/client/connection/src/client/rpc.ts index f8bacb1553..c7b609c01b 100644 --- a/packages/client/connection/src/client/rpc.ts +++ b/packages/client/connection/src/client/rpc.ts @@ -2,21 +2,34 @@ import { RpcId, - serverResponseSchema, type ClientRequest, + type RpcId as RpcIdType, } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { ClientConnectionRpc } from '../rpc.ts' +import type { ClientConnectionRpc, ConnectionRpcResult } from '../rpc.ts' import { randomUuid } from './random-uuid.ts' const INTERNAL_BASE = 'http://dsh.internal' const CHANNEL_PATTERN = /^\/[A-Za-z0-9._~-]+$/ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ +/** Transport this caller posts through; same signature as the global `fetch`. */ +export type RpcFetch = (input: URL, init: RequestInit) => Promise + +/** Worker-local opener for decoded Gateway Remote streams. */ +export type RpcStreamOpen = ( + endpoint: string, + payload: unknown, + signal: AbortSignal, +) => AsyncIterable + /** * Create the browser-backed generic RPC caller. + * @param doFetch - transport override; defaults to the page's global fetch. + * @param openStream - optional worker-local Gateway stream carrier. * @returns caller that owns request correlation and response-envelope validation. */ -export function createWebConnectionRpc(): ClientConnectionRpc { +export function createWebConnectionRpc(doFetch?: RpcFetch, openStream?: RpcStreamOpen): ClientConnectionRpc { + const send: RpcFetch = doFetch ?? ((input, init) => globalThis.fetch(input, init)) return { async call(channel, endpoint, payload, signal) { assertTarget(channel, endpoint) @@ -27,7 +40,7 @@ export function createWebConnectionRpc(): ClientConnectionRpc { method: endpoint, payload, } - const response = await globalThis.fetch( + const response = await send( new URL(`${channel}/${endpoint}`, resolveBase()), { method: 'POST', @@ -39,15 +52,59 @@ export function createWebConnectionRpc(): ClientConnectionRpc { if (!response.ok) { throw new Error(`transport failure for ${channel}/${endpoint}: HTTP ${response.status}`) } - const full = serverResponseSchema.parse(await response.json()) + const full = parseConnectionResponse(await response.json()) if (full.rpcId !== rpcId) { throw new Error(`rpcId mismatch for ${endpoint}: sent ${rpcId}, got ${full.rpcId}`) } return full.result }, + ...openStream === undefined ? {} : { + open(channel, endpoint, payload, signal) { + assertTarget(channel, endpoint) + if (channel !== '/api') { + throw new Error(`connection: worker-local streams require the /api channel, got ${JSON.stringify(channel)}`) + } + return openStream(endpoint, payload, signal) + }, + }, } } +function parseConnectionResponse(value: unknown): { + readonly rpcId: RpcIdType + readonly result: ConnectionRpcResult +} { + if (!isRecord(value) || value.type !== 'server-response' || typeof value.rpcId !== 'string') { + throw new TypeError('connection: invalid server-response envelope') + } + const result = value.result + if (!isRecord(result)) throw new TypeError('connection: invalid server-response result') + if (result.ok === true) { + return { + rpcId: RpcId(value.rpcId), + result: { ok: true, value: result.value }, + } + } + if (result.ok !== false || !isRecord(result.error)) { + throw new TypeError('connection: invalid server-response result') + } + const error = result.error + if (typeof error.code !== 'string' || typeof error.message !== 'string' || !isRecord(error.details)) { + throw new TypeError('connection: invalid server-response failure') + } + return { + rpcId: RpcId(value.rpcId), + result: { + ok: false, + error: { code: error.code, message: error.message, details: error.details }, + }, + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function resolveBase(): string { const location = (globalThis as { location?: { origin?: string } }).location return location?.origin !== undefined && location.origin !== 'null' ? location.origin : INTERNAL_BASE diff --git a/packages/client/connection/src/client/web-api-client.ts b/packages/client/connection/src/client/web-api-client.ts index a2c2d95b7b..6716f252a5 100644 --- a/packages/client/connection/src/client/web-api-client.ts +++ b/packages/client/connection/src/client/web-api-client.ts @@ -1,91 +1,10 @@ -/** Browser API carrier: HTTP upstream plus one WebSocket per downstream event stream. */ +/** Browser API carrier for unary HTTP calls. */ -import type { ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest } from './api.ts' import { AbstractApiClient } from './api.ts' -import { hostFrameSchema, muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema' -import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema' -import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../api-path.ts' -type SocketItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } -type Parser = { parse(value: unknown): F } - -/** Browser platform subclass: unary/respond use fetch; mux/host use downlink-only WebSockets. */ +/** Browser platform subclass supplying fetch for unary calls. */ export class WebApiClient extends AbstractApiClient { protected doFetch(input: URL, init?: RequestInit): Promise { return globalThis.fetch(input, init) } - - protected override openMux( - _payload: Parameters[0]['payload'], - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.readWebSocket(MUX_EVENTS_PATH, signal, muxFrameSchema, onOpen) - } - - protected override openHost( - _payload: Parameters[0]['payload'], - signal: AbortSignal, - onOpen?: () => void, - ): AsyncIterable> { - return this.readWebSocket(HOST_EVENTS_PATH, signal, hostFrameSchema, onOpen) - } - - private async *readWebSocket( - path: string, - signal: AbortSignal, - frameSchema: Parser, - onOpen?: () => void, - ): AsyncGenerator> { - const url = new URL(path, this.resolveBase()) - url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:' - const socket = new WebSocket(url) - const inbox: SocketItem[] = [] - let wake: (() => void) | undefined - const enqueue = (item: SocketItem): void => { - inbox.push(item) - wake?.() - wake = undefined - } - const handleOpen = (): void => { onOpen?.() } - const handleMessage = (event: MessageEvent): void => { - let full: ServerRequest - let frame: F - try { - if (typeof event.data !== 'string') throw new Error('binary WebSocket frame') - full = serverRequestSchema.parse(JSON.parse(event.data)) - frame = frameSchema.parse(full.payload) - } catch (error) { - console.error(`[client-connection] dropping malformed WebSocket frame on ${path}:`, error) - return - } - this.onEnvelope(full) - enqueue({ kind: 'frame', envelope: { rpcId: full.rpcId, payload: frame } }) - } - const handleClose = (): void => { enqueue({ kind: 'end' }) } - const handleAbort = (): void => { - if (socket.readyState === WebSocket.CONNECTING || socket.readyState === WebSocket.OPEN) socket.close() - } - socket.addEventListener('open', handleOpen) - socket.addEventListener('message', handleMessage) - socket.addEventListener('close', handleClose, { once: true }) - signal.addEventListener('abort', handleAbort, { once: true }) - if (signal.aborted) handleAbort() - try { - while (true) { - while (inbox.length > 0) { - const item = inbox.shift() as SocketItem - if (item.kind === 'end') return - yield item.envelope - } - await new Promise((resolve) => { wake = resolve }) - } - } finally { - signal.removeEventListener('abort', handleAbort) - socket.removeEventListener('open', handleOpen) - socket.removeEventListener('message', handleMessage) - socket.removeEventListener('close', handleClose) - handleAbort() - } - } } diff --git a/packages/client/connection/src/http-bridge.ts b/packages/client/connection/src/http-bridge.ts index c26d83b6b7..b404e65103 100644 --- a/packages/client/connection/src/http-bridge.ts +++ b/packages/client/connection/src/http-bridge.ts @@ -6,10 +6,10 @@ import type { IncomingMessage, ServerResponse } from 'node:http' /** Default carrier cap for all HTTP RPC bodies: sized for the default - * aggregate image limit (100 MiB) after base64 expansion plus envelope - * headroom (~134.3 MiB required), rounded up for slack. The bridge buffers + * aggregate image limit (200 MiB) after base64 expansion plus envelope + * headroom (~267.7 MiB required), rounded up for slack. The bridge buffers * each body in memory, so this cap is also the per-request resident bound. */ -export const DEFAULT_MAX_REQUEST_BODY_BYTES = 160 * 1024 * 1024 +export const DEFAULT_MAX_REQUEST_BODY_BYTES = 300 * 1024 * 1024 /** Transport-independent request handler consumed by the Host HTTP bridge. */ export interface FetchHandler { @@ -23,7 +23,7 @@ export interface FetchHandler { /** * Bridge one node:http request to the fetch-shaped handler (client close - * aborts; SSE bodies stream out chunk by chunk). + * aborts; response bodies stream out chunk by chunk). * @param req - incoming node:http request (fully read before dispatch). * @param res - node:http response the bridge writes and owns to completion. * @param apiHandler - fetch-shaped API carrier the request is dispatched to. @@ -38,8 +38,8 @@ export async function bridge( const abort = new AbortController() // Client-disconnect detection MUST hang off the response, not the request: // since Node 16, IncomingMessage 'close' fires as soon as the request body is - // fully consumed (immediately for a bodyless GET), which would abort every SSE - // stream right after open. ServerResponse 'close' fires on connection teardown; + // fully consumed (immediately for a bodyless GET), which would abort a + // streaming response right after open. ServerResponse 'close' fires on connection teardown; // writableEnded distinguishes a normal end() from the client going away. res.on('close', () => { if (!res.writableEnded) abort.abort() @@ -80,7 +80,7 @@ export async function bridge( } for await (const chunk of response.body) { // Backpressure: a false return means the socket buffer is full — wait for drain - // instead of buffering unboundedly (slow/suspended SSE consumers). 'close' also + // instead of buffering unboundedly (slow or suspended consumers). 'close' also // resolves so a mid-wait disconnect can't park this loop forever; the close // handler above aborts the handler stream, which then ends the iteration. if (!res.write(chunk)) { diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 35084918e8..c7203f38d8 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -2,26 +2,31 @@ import type { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' +import type {} from '@deepseek-ai/dsh-credentials' // Activates the webServer Context merge used below. -import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' +import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' -import { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' +import { API_PATH } from './api-path.ts' import { bridge, DEFAULT_MAX_REQUEST_BODY_BYTES } from './http-bridge.ts' -import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' +import { assertTrustedAuthority } from './api-request-trust.ts' +import { BrowserAuth } from './browser-auth.ts' import { HostConnectionService } from './rpc-host.ts' -import { rejectWebSocketUpgrade, WebSocketDownlinks } from './websocket-downlink.ts' export type { - ConnectionRpcAuthority, + ConnectionIndexRequest, + ConnectionIndexResponse, ConnectionRpcEndpointMatcher, + ConnectionRpcFailure, ConnectionRpcHandler, - ConnectionRpcHandlerOptions, + ConnectionRequestRejection, + ConnectionRpcResult, + ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' export { HostConnectionService } from './rpc-host.ts' -export { API_PATH, HOST_EVENTS_PATH, MUX_EVENTS_PATH } from './api-path.ts' +export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' @@ -44,7 +49,7 @@ function assertImageBodyCapacity(ctx: Context, maxRequestBodyBytes: number): voi } /** Services required before providing Connection; API Proxy is an optional `/api` fallback. */ -export const inject = ['webServer'] +export const inject = ['webServer', 'credentials'] /** Plugin config: the deployment's non-loopback serving authorities. */ export interface ConnectionConfig { @@ -53,144 +58,63 @@ export interface ConnectionConfig { * port-less `host` matching any port. The /api trust fence refuses any * request whose Host is neither loopback nor listed here, so a * non-loopback (`0.0.0.0`) deployment must declare the names it is reached - * by (the dsh CLI derives the machine's LAN IP literals itself). An entry - * that is not a bare, canonical authority fails the plugin load. + * by; the Web runtime derives LAN IP literals from an active all-interface + * bind. An entry that is not a bare, canonical authority fails plugin load. */ trustedHosts?: string[] - /** Maximum buffered JSON body for every `/api` request. */ + /** Absolute browser-session lifetime in days. Default: 30. */ + cookieMaxAgeDays?: number + /** Maximum buffered JSON body for every `/api` request. Default: 300 MiB. */ maxRequestBodyBytes?: number } export const Config: z = z.object({ trustedHosts: z.array(String).default([]), + cookieMaxAgeDays: z.natural().min(1).default(30), maxRequestBodyBytes: z.natural().min(1).default(DEFAULT_MAX_REQUEST_BODY_BYTES), }) -/** - * Methods gated to loopback even on a trusted-host deployment. Native dialogs - * act on the host machine; the settings and credential domains mutate the - * user's configuration and secret store, and READING them is equally - * privileged — `settings.describe` returns every exposed namespace's - * configuration and `credentials.describe` reports whether an arbitrary - * environment-variable name is configured and where from, which is - * reconnaissance no anonymous caller should have. `trustedHosts` is a - * DNS-rebinding fence, explicitly not authentication, so the whole - * configuration plane stays loopback-same-origin until a real authentication - * layer exists. `llm.discoverModels` belongs to that plane on both counts: it - * carries a draft credential, and it makes the HOST issue a GET to a URL the - * caller chose and reports back the status or the parsed body — an anonymous - * LAN caller would have a probe for whatever the host can reach and the - * browser cannot. - * - * The model catalog (`llm.providers`, `llm.models`) is deliberately NOT here: - * it carries provider ids, display names, and model lists — no endpoints, - * keys, or key state — and a LAN client's model picker legitimately needs it. - */ -const PRIVILEGED_METHODS = new Set([ - // A preset composition names the plugins a session runs, so reading one is - // reconnaissance; copy and remove rearrange what the deployment offers, and - // openDocument drives the host desktop — all more than the roster beside - // them. (Authoring is copy-only, so no method here accepts composition text - // or a path; the pin is about who may manage the roster at all.) - // - // CHOOSING one is not pinned, and `agentPreset.list` is not either. Picking a - // preset looks like escalation — one of them mounts the toolset that edits the - // live runtime — but `session.create` already takes an `agentPreset`, so - // pinning only the switch would leave the same capability one method over. - // The deeper reason is that the capability is not the preset's to grant: the - // deployment's own default already carries `bash` and the filesystem tools, so - // any caller that may start a session at all can already run commands as this - // process. Pinning the switch would be a fence beside an open gate. - 'agentPreset.read', - 'agentPreset.copy', - 'agentPreset.openDocument', - 'agentPreset.remove', - 'host.pickDirectory', - 'host.openPath', - 'settings.describe', - 'settings.openDocument', - 'settings.update', - 'settings.replace', - 'settings.mutate', - 'credentials.describe', - 'credentials.set', - 'credentials.unset', - 'llm.discoverModels', -]) - /** * Mounts the API gateway under the browser transport prefix. Every request on - * the prefix passes the browser-trust fence first (DNS-rebinding and - * cross-site defense — [api-request-trust](./api-request-trust.ts)); - * privileged methods additionally pass it with an empty trust list, which - * pins them to loopback. + * the prefix passes the Host/Origin browser-trust fence and persistent browser + * authentication before dispatch. * @param ctx - Host plugin context. * @param config - resolved plugin config (schema defaults applied). */ -export function apply(ctx: Context, config?: ConnectionConfig): void { +export async function apply(ctx: Context, config?: ConnectionConfig): Promise { // The Loader resolves schema defaults; hand-built test contexts may pass none. const trustedHosts = config?.trustedHosts ?? [] + const cookieMaxAgeDays = config?.cookieMaxAgeDays ?? 30 const maxRequestBodyBytes = config?.maxRequestBodyBytes ?? DEFAULT_MAX_REQUEST_BODY_BYTES // Config boundary: a malformed entry fails the load loudly here rather than // silently authorizing its hostname prefix at request time. for (const entry of trustedHosts) assertTrustedAuthority(entry) if (ctx.get('apiProxy') !== undefined) assertImageBodyCapacity(ctx, maxRequestBodyBytes) - const connection = new HostConnectionService(ctx, trustedHosts) + const connection = new HostConnectionService( + ctx, + trustedHosts, + await BrowserAuth.create(ctx.root, ctx.credentials, cookieMaxAgeDays), + ) const fetchHandler = connection.createSharedFetchHandler(API_PATH, { async fetch(request) { - const pathname = new URL(request.url).pathname - const method = pathname.startsWith(`${API_PATH}/`) - ? pathname.slice(API_PATH.length + 1) - : undefined - if (method !== undefined - && PRIVILEGED_METHODS.has(method) - && !isTrustedApiRequest(request, [])) { - return new Response('forbidden', { status: 403 }) - } - if (request.method === 'GET' && (pathname === MUX_EVENTS_PATH || pathname === HOST_EVENTS_PATH)) { - return new Response('upgrade required', { - status: 426, - headers: { connection: 'Upgrade', upgrade: 'websocket' }, - }) - } const apiProxy = ctx.get('apiProxy') if (apiProxy === undefined) return new Response('not found', { status: 404 }) - return toFetchHandler(apiProxy).fetch(request) + return await toFetchHandler(apiProxy).fetch(request) }, }) const route: WebRoute = { kind: 'prefix', path: API_PATH, handler: async (req, res) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') + const rejection = connection.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') return } await bridge(req, res, fetchHandler, maxRequestBodyBytes) }, } ctx.effect(() => ctx.webServer.register(route), 'client-connection: /api route') - ctx.inject(['apiProxy'], (apiCtx) => { - assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) - const downlinks = new WebSocketDownlinks(apiCtx.apiProxy) - const registerDownlink = ( - path: string, - handle: WebUpgradeRoute['handler'], - ): void => { - apiCtx.effect(() => apiCtx.webServer.registerUpgrade({ - path, - handler: (req, socket, head) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - rejectWebSocketUpgrade(socket) - return - } - return handle(req, socket, head) - }, - }), `client-connection: ${path} WebSocket`) - } - apiCtx.effect(() => () => downlinks.close(), 'client-connection: WebSocket downlinks') - registerDownlink(MUX_EVENTS_PATH, (req, socket, head) => { downlinks.handleMux(req, socket, head) }) - registerDownlink(HOST_EVENTS_PATH, (req, socket, head) => { downlinks.handleHost(req, socket, head) }) - }) + ctx.inject(['apiProxy'], (apiCtx) => { assertImageBodyCapacity(apiCtx, maxRequestBodyBytes) }) } diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 78394263cf..5a187545b5 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -15,11 +15,12 @@ export const name = 'client-connection-invariant' export const inject = ['invariants'] /** - * No runtime invariant: the wire layer emits no cordis events and owns no - * mutable cross-plugin relation — stream/reconnect sequencing is exercised - * directly by its behavior specs, rpcId round-trip discipline is owned by the - * apiproxy contract layer, and the node half's single route registration's - * register/dispose symmetry is audited by the webserver package's invariant. + * No runtime invariant: browser-session verification reads the credential + * record asynchronously at the request that authorizes work, while the + * credentials companion owns record commit-event lifetime. Stream/reconnect + * sequencing is exercised directly by behavior specs, rpcId round-trip + * discipline belongs to apiproxy, and route register/dispose symmetry is + * audited by the webserver companion. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 0da66c85a7..4f1e78341b 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -9,15 +9,19 @@ import { type RpcError, type RpcErrorDetailsMap, type RpcId as RpcIdType, - type ServerResponse as RpcServerResponse, } from '@deepseek-ai/dsh-host-apiproxy/api' import { bridge, type FetchHandler } from './http-bridge.ts' import { isTrustedApiRequest } from './api-request-trust.ts' import { API_PATH } from './api-path.ts' +import type { BrowserAuth } from './browser-auth.ts' import type { + ConnectionIndexRequest, + ConnectionIndexResponse, ConnectionRpcEndpointMatcher, ConnectionRpcHandler, - ConnectionRpcHandlerOptions, + ConnectionRpcResult, + ConnectionRequestRejection, + ConnectionTrustRequest, HostConnectionHandle, HostConnectionRpc, } from './rpc.ts' @@ -29,7 +33,12 @@ const ENDPOINT_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ interface ConnectionRpcInterceptor { readonly matches: ConnectionRpcEndpointMatcher readonly fetchHandler: FetchHandler - readonly options: ConnectionRpcHandlerOptions +} + +interface ConnectionServerResponse { + readonly type: 'server-response' + readonly rpcId: RpcIdType + readonly result: ConnectionRpcResult } declare module '@deepseek-ai/cordis' { @@ -46,9 +55,14 @@ export class HostConnectionService extends Service implements HostConnectionHand /** * Provide the Host half over the active HTTP server. * @param ctx - owning Connection plugin context. - * @param trustedHosts - deployment authorities accepted by trusted-host channels. + * @param trustedHosts - deployment authorities accepted by the Host/Origin fence. + * @param browserAuth - process token and persistent browser-session owner. */ - constructor(ctx: Context, private readonly trustedHosts: readonly string[]) { + constructor( + ctx: Context, + private readonly trustedHosts: readonly string[], + private readonly browserAuth: BrowserAuth, + ) { super(ctx, 'connection') } @@ -56,12 +70,28 @@ export class HostConnectionService extends Service implements HostConnectionHand get rpc(): HostConnectionRpc { const owner = this.ctx return { - handle: (channel, handler, options) => this.register(owner, channel, handler, options), - intercept: (channel, matches, handler, options) => - this.registerInterceptor(owner, channel, matches, handler, options), + handle: (channel, handler) => this.register(owner, channel, handler), + intercept: (channel, matches, handler) => + this.registerInterceptor(owner, channel, matches, handler), } } + /** Apply the configured Host/Origin fence, then browser authentication. */ + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection { + if (!isTrustedApiRequest(request, this.trustedHosts)) return 403 + return this.browserAuth.isAuthenticated(request) ? undefined : 401 + } + + /** Authenticate an index request through the process-token exchange or cookie. */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean { + return this.browserAuth.authorizeIndex(request, response) + } + + /** Add this process's launch token to the clean application URL. */ + authenticatedUrl(baseUrl: string): string { + return this.browserAuth.authenticatedUrl(baseUrl) + } + /** * Compose one shared-channel Fetch handler from its interceptor and fallback. * @param channel - shared channel mounted by Connection. @@ -79,9 +109,6 @@ export class HostConnectionService extends Service implements HostConnectionHand if (endpoint === undefined || interceptor === undefined || !interceptor.matches(endpoint)) { return fallback.fetch(request) } - if (interceptor.options.authority === 'loopback' && !isTrustedApiRequest(request, [])) { - return Promise.resolve(new Response('forbidden', { status: 403 })) - } return interceptor.fetchHandler.fetch(request) }, } @@ -91,18 +118,17 @@ export class HostConnectionService extends Service implements HostConnectionHand owner: Context, channel: string, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise { assertChannel(channel) - const trustedHosts = options.authority === 'loopback' ? [] : this.trustedHosts const fetchHandler = rpcFetchHandler(channel, handler) const route: WebRoute = { kind: 'prefix', path: channel, handler: async (req, res) => { - if (!isTrustedApiRequest(req, trustedHosts)) { - res.writeHead(403) - res.end('forbidden') + const rejection = this.requestRejection(req) + if (rejection !== undefined) { + res.writeHead(rejection) + res.end(rejection === 401 ? 'unauthorized' : 'forbidden') return } await bridge(req, res, fetchHandler) @@ -119,7 +145,6 @@ export class HostConnectionService extends Service implements HostConnectionHand channel: string, matches: ConnectionRpcEndpointMatcher, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise { if (channel !== API_PATH) { throw new Error(`connection: invalid shared RPC channel ${JSON.stringify(channel)}`) @@ -127,7 +152,6 @@ export class HostConnectionService extends Service implements HostConnectionHand const interceptor: ConnectionRpcInterceptor = { matches, fetchHandler: rpcFetchHandler(channel, handler), - options, } return owner.effect(() => { if (this.interceptors.has(channel)) { @@ -212,8 +236,8 @@ function errorResponse(rpcId: RpcIdType, error: RpcError): Response { return fullResponse(rpcId, { ok: false, error }) } -function fullResponse(rpcId: RpcIdType, result: RpcServerResponse['result']): Response { - const body: RpcServerResponse = { type: 'server-response', rpcId, result } +function fullResponse(rpcId: RpcIdType, result: ConnectionRpcResult): Response { + const body: ConnectionServerResponse = { type: 'server-response', rpcId, result } return Response.json(body) } diff --git a/packages/client/connection/src/rpc.ts b/packages/client/connection/src/rpc.ts index e1260f00e8..1f879963af 100644 --- a/packages/client/connection/src/rpc.ts +++ b/packages/client/connection/src/rpc.ts @@ -1,14 +1,36 @@ /** Generic unary RPC contracts shared by the Host and Client Connection halves. */ -import type { RpcResult } from '@deepseek-ai/dsh-host-apiproxy/api' +/** Carrier-neutral failure returned by one logical RPC endpoint. */ +export interface ConnectionRpcFailure { + readonly code: string + readonly message: string + readonly details: object +} -/** Trust fence applied before a Host RPC channel reaches its handler. */ -export type ConnectionRpcAuthority = 'trusted-host' | 'loopback' +/** Carrier-neutral result returned by one logical RPC endpoint. */ +export type ConnectionRpcResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: ConnectionRpcFailure } -/** Registration policy for one logical RPC channel. */ -export interface ConnectionRpcHandlerOptions { - /** Browser authority accepted by every endpoint in this channel. */ - readonly authority: ConnectionRpcAuthority +/** HTTP request facts consumed by browser trust and authentication. */ +export interface ConnectionTrustRequest { + /** Request headers supplied by either the Fetch or node:http representation. */ + readonly headers: Headers | Readonly> +} + +/** HTTP status returned before dispatch, or undefined when the request may proceed. */ +export type ConnectionRequestRejection = 401 | 403 | undefined + +/** Root/index request facts used by the browser-token exchange. */ +export interface ConnectionIndexRequest extends ConnectionTrustRequest { + readonly method?: string | undefined + readonly url?: string | undefined +} + +/** Root/index response operations owned by the browser-token exchange. */ +export interface ConnectionIndexResponse { + writeHead(status: number, headers?: Readonly>): unknown + end(body?: string): unknown } /** Handler invoked after Connection has decoded the transport envelope. */ @@ -16,7 +38,7 @@ export type ConnectionRpcHandler = ( endpoint: string, payload: unknown, signal: AbortSignal, -) => Promise> +) => Promise> /** Synchronous ownership test for one endpoint on a shared RPC channel. */ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean @@ -24,16 +46,14 @@ export type ConnectionRpcEndpointMatcher = (endpoint: string) => boolean /** Host registry for logical RPC channels carried by the current transport. */ export interface HostConnectionRpc { /** - * Register one absolute channel prefix and its trust policy. + * Register one authenticated absolute channel prefix. * @param channel - absolute logical channel such as `/rpc`. * @param handler - decoded endpoint handler returning the existing RPC result shape. - * @param options - channel trust policy. * @returns asynchronous disposer removing the channel and its physical route. */ handle( channel: string, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise /** @@ -41,14 +61,12 @@ export interface HostConnectionRpc { * @param channel - reserved shared channel; currently `/api`. * @param matches - synchronous endpoint ownership test. * @param handler - decoded endpoint handler returning the existing RPC result shape. - * @param options - trust policy for every endpoint claimed by this interceptor. * @returns asynchronous disposer removing the interceptor. */ intercept( channel: '/api', matches: ConnectionRpcEndpointMatcher, handler: ConnectionRpcHandler, - options: ConnectionRpcHandlerOptions, ): () => Promise } @@ -56,6 +74,29 @@ export interface HostConnectionRpc { export interface HostConnectionHandle { /** Generic RPC channel registry. */ readonly rpc: HostConnectionRpc + + /** + * Apply Connection's Host/Origin checks and browser authentication to + * another Web route. + * @param request - request headers from the HTTP or upgrade request. + * @returns rejection status, or undefined when the route may accept the request. + */ + requestRejection(request: ConnectionTrustRequest): ConnectionRequestRejection + + /** + * Authenticate one frontend index request, owning a token redirect or 401. + * @param request - root or configured-index HTTP request. + * @param response - response owned when the result is false. + * @returns true only when the frontend may serve index.html. + */ + authorizeIndex(request: ConnectionIndexRequest, response: ConnectionIndexResponse): boolean + + /** + * Add the fresh process token to an ordinary Web application URL. + * @param baseUrl - clean canonical browser origin. + * @returns root URL accepted by {@link authorizeIndex} for initial login. + */ + authenticatedUrl(baseUrl: string): string } /** Client caller for logical RPC channels carried by the current transport. */ @@ -66,12 +107,28 @@ export interface ClientConnectionRpc { * @param endpoint - channel-relative endpoint such as `goals/create`. * @param payload - channel-owned request payload. * @param signal - optional caller cancellation. - * @returns the existing RPC success/error result; correlation stays inside Connection. + * @returns the endpoint-owned success/error result; correlation stays inside Connection. */ call( channel: string, endpoint: string, payload: unknown, signal?: AbortSignal, - ): Promise> + ): Promise> + + /** + * Open an in-process logical stream when the selected carrier supplies one. + * Browser transports omit this method; API Gateway owns their WebSocket mux. + * @param channel - absolute logical channel such as `/api`. + * @param endpoint - channel-relative endpoint such as `session/follow`. + * @param payload - channel-owned request payload. + * @param signal - caller cancellation for this logical stream. + * @returns decoded stream values from the in-process carrier. + */ + readonly open?: ( + channel: string, + endpoint: string, + payload: unknown, + signal: AbortSignal, + ) => AsyncIterable } diff --git a/packages/client/connection/src/websocket-downlink.ts b/packages/client/connection/src/websocket-downlink.ts deleted file mode 100644 index 72ae5e94ef..0000000000 --- a/packages/client/connection/src/websocket-downlink.ts +++ /dev/null @@ -1,153 +0,0 @@ -/** Host-side WebSocket carrier for the two server-to-browser event streams. */ - -import { randomUUID } from 'node:crypto' -import type { IncomingMessage } from 'node:http' -import type { Duplex } from 'node:stream' -import WebSocket, { WebSocketServer } from 'ws' -import type { - ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' - -type Frame = MuxFrame | HostFrame - -function serverRequest(frame: RpcRequest): ServerRequest { - return { - type: 'server-request', - rpcId: frame.rpcId, - method: frame.payload.type, - payload: frame.payload, - } -} - -function send(socket: WebSocket, frame: RpcRequest): Promise { - return new Promise((resolve, reject) => { - if (socket.readyState !== WebSocket.OPEN) { - reject(new Error('websocket downlink closed before frame delivery')) - return - } - socket.send(JSON.stringify(serverRequest(frame)), (error) => { - if (error) reject(error) - else resolve() - }) - }) -} - -function failureFrame(error: unknown): RpcRequest { - return { - rpcId: RpcId(randomUUID()), - payload: { - type: 'stream/error', - error: { code: 'internal', message: String(error), details: {} }, - }, - } -} - -/** - * Owns WebSocket negotiation and frame pumping for the connection plugin's - * two downlinks. Client messages are a protocol violation: upstream traffic - * remains on HTTP. - */ -export class WebSocketDownlinks { - private readonly server = new WebSocketServer({ noServer: true }) - private readonly pumps = new Set>() - - /** @param api - host API supplying the typed event streams. */ - constructor(private readonly api: ApiProxy) {} - - /** - * Upgrade one socket and pump the mux stream until either side closes. - * @param req - HTTP upgrade request. - * @param socket - Raw socket transferred by the HTTP server. - * @param head - Bytes already read after the upgrade headers. - */ - handleMux(req: IncomingMessage, socket: Duplex, head: Buffer): void { - this.upgrade(req, socket, head, signal => this.api.events.mux({ - rpcId: RpcId(randomUUID()), - payload: {}, - }, signal)) - } - - /** - * Upgrade one socket and pump the host stream until either side closes. - * @param req - HTTP upgrade request. - * @param socket - Raw socket transferred by the HTTP server. - * @param head - Bytes already read after the upgrade headers. - */ - handleHost(req: IncomingMessage, socket: Duplex, head: Buffer): void { - this.upgrade(req, socket, head, signal => this.api.events.host({ - rpcId: RpcId(randomUUID()), - payload: {}, - }, signal)) - } - - /** - * Terminate owned sockets and await the no-server acceptor plus frame pumps. - * @returns A promise resolving after every socket and source iterator stops. - */ - async close(): Promise { - for (const socket of this.server.clients) socket.terminate() - await new Promise((resolve, reject) => { - this.server.close((error) => { - if (error === undefined) resolve() - else reject(error) - }) - }) - await Promise.all(this.pumps) - } - - private upgrade( - req: IncomingMessage, - socket: Duplex, - head: Buffer, - open: (signal: AbortSignal) => AsyncIterable>, - ): void { - this.server.handleUpgrade(req, socket, head, (websocket) => { - const abort = new AbortController() - websocket.once('close', () => { abort.abort() }) - websocket.once('error', () => { abort.abort() }) - websocket.once('message', () => { - websocket.close(1008, 'downlink only') - }) - const pump = this.pump(websocket, open(abort.signal), abort) - this.pumps.add(pump) - void pump.then(() => { this.pumps.delete(pump) }) - }) - } - - private async pump( - socket: WebSocket, - frames: AsyncIterable>, - abort: AbortController, - ): Promise { - try { - for await (const frame of frames) await send(socket, frame) - } catch (error) { - if (!abort.signal.aborted) { - try { - await send(socket, failureFrame(error)) - } catch { - // Socket loss won the race; no downstream remains to receive the failure frame. - } - } - } finally { - abort.abort() - if (socket.readyState === WebSocket.OPEN) socket.close() - } - } -} - -/** - * Reject an untrusted upgrade before protocol negotiation. - * @param socket - Raw HTTP socket that remains owned by the caller. - */ -export function rejectWebSocketUpgrade(socket: Duplex): void { - socket.end([ - 'HTTP/1.1 403 Forbidden', - 'Connection: close', - 'Content-Type: text/plain; charset=utf-8', - 'Content-Length: 9', - '', - 'forbidden', - ].join('\r\n')) -} diff --git a/packages/client/connection/tests/api-request-trust.host.spec.ts b/packages/client/connection/tests/api-request-trust.host.spec.ts index f145230a8b..359a461e94 100644 --- a/packages/client/connection/tests/api-request-trust.host.spec.ts +++ b/packages/client/connection/tests/api-request-trust.host.spec.ts @@ -68,6 +68,13 @@ describe('isTrustedApiRequest', () => { expect(isTrustedApiRequest(request({ host: 'localhost:3080', 'sec-fetch-site': 'same-origin' }), [])).toBe(true) }) + it('reads Fetch Headers while preserving absent browser markers', () => { + expect(isTrustedApiRequest({ headers: new Headers({ host: '127.0.0.1:3080' }) }, [])).toBe(true) + expect(isTrustedApiRequest({ + headers: new Headers({ host: '127.0.0.1:3080', origin: 'http://evil.example' }), + }, [])).toBe(false) + }) + it('assertTrustedAuthority accepts bare authorities and throws on anything more', () => { for (const entry of ['harness.internal', 'harness.internal:3080', 'HARNESS.internal:80', '10.0.0.9', '[::1]:3080']) { expect(() => { assertTrustedAuthority(entry) }).not.toThrow() diff --git a/packages/client/connection/tests/browser-auth.host.spec.ts b/packages/client/connection/tests/browser-auth.host.spec.ts new file mode 100644 index 0000000000..3f06672ff1 --- /dev/null +++ b/packages/client/connection/tests/browser-auth.host.spec.ts @@ -0,0 +1,250 @@ +/** Browser launch-token and persistent-cookie behavior. */ + +import { createHmac } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { CredentialProvider } from '@deepseek-ai/dsh-credentials' +import { BrowserAuth } from '../src/browser-auth.ts' +import type { ConnectionIndexRequest, ConnectionIndexResponse } from '../src/rpc.ts' +import { RecordCredentials } from './browser-credentials.ts' + +function signedCookie(store: RecordCredentials, name: string, payload: unknown): string { + const body = typeof payload === 'string' + ? Buffer.from(payload, 'utf8').toString('base64url') + : Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + return signedBodyCookie(store, name, body) +} + +function signedBodyCookie(store: RecordCredentials, name: string, body: string): string { + const record = store.record + if (record?.kind !== 'grant' || typeof record.payload !== 'object' || record.payload === null) { + throw new Error('test credential store has no signing secret') + } + const secret: unknown = Reflect.get(record.payload, 'secret') + if (typeof secret !== 'string') throw new Error('test credential record has no string secret') + const signature = createHmac('sha256', Buffer.from(secret, 'base64url')).update(body).digest('base64url') + return `${name}=v1.${body}.${signature}` +} + +interface ResponseState { + status?: number + headers?: Readonly> + body?: string +} + +function response(): { value: ConnectionIndexResponse; state: ResponseState } { + const state: ResponseState = {} + return { + value: { + writeHead(status, headers) { + state.status = status + if (headers !== undefined) state.headers = headers + }, + end(body) { + if (body !== undefined) state.body = body + }, + }, + state, + } +} + +function credentials(store: RecordCredentials): CredentialProvider { + return store as unknown as CredentialProvider +} + +function createAuth( + store: RecordCredentials, + maxAgeDays = 30, + processOwner: object = {}, +): Promise { + return BrowserAuth.create(processOwner, credentials(store), maxAgeDays) +} + +function request(url: string, authority = '127.0.0.1:3080', init?: { + cookie?: string + method?: string +}): ConnectionIndexRequest { + return { + method: init?.method ?? 'GET', + url, + headers: { + host: authority, + ...init?.cookie === undefined ? {} : { cookie: init.cookie }, + }, + } +} + +function exchange( + auth: BrowserAuth, + authority = '127.0.0.1:3080', +): { cookie: string; launchUrl: string; state: ResponseState } { + const launchUrl = auth.authenticatedUrl(`http://${authority}`) + const target = new URL(launchUrl) + const res = response() + expect(auth.authorizeIndex(request(`${target.pathname}${target.search}`, authority), res.value)).toBe(false) + const setCookie = res.state.headers?.['set-cookie'] + if (setCookie === undefined) throw new Error('token exchange did not set a cookie') + return { cookie: setCookie.split(';', 1)[0]!, launchUrl, state: res.state } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('BrowserAuth', () => { + it('mints one process token and a persistent authority-bound cookie', async () => { + const store = new RecordCredentials() + const processOwner = {} + const first = await createAuth(store, 30, processOwner) + const login = exchange(first) + + expect(login.state).toMatchObject({ + status: 303, + headers: { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + }, + }) + expect(login.state.headers?.['set-cookie']).toMatch(/; Max-Age=2592000; Path=\/; Expires=.*; HttpOnly; SameSite=Strict$/u) + expect(login.state.headers?.['set-cookie']).not.toContain('Secure') + expect(first.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + expect(first.isAuthenticated({ + headers: new Headers({ host: '127.0.0.1:3080', cookie: login.cookie }), + })).toBe(true) + expect(first.isAuthenticated({ headers: new Headers() })).toBe(false) + expect(first.isAuthenticated(request('/', 'localhost:3080', { cookie: login.cookie }))).toBe(false) + expect(first.isAuthenticated(request('/', '127.0.0.1:3081', { cookie: login.cookie }))).toBe(false) + + const reloaded = await createAuth(store, 30, processOwner) + expect(reloaded.authenticatedUrl('http://127.0.0.1:3080')).toBe(login.launchUrl) + expect(reloaded.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + + const restarted = await createAuth(store) + expect(new URL(restarted.authenticatedUrl('http://127.0.0.1:3080')).searchParams.get('token')) + .not.toBe(new URL(login.launchUrl).searchParams.get('token')) + expect(restarted.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: login.cookie }))).toBe(true) + const staleUrl = new URL(login.launchUrl) + const redirected = response() + expect(restarted.authorizeIndex(request( + `${staleUrl.pathname}${staleUrl.search}`, + '127.0.0.1:3080', + { cookie: login.cookie }, + ), redirected.value)).toBe(false) + expect(redirected.state).toEqual({ + status: 303, + headers: { + 'cache-control': 'no-store', + 'location': '/', + 'referrer-policy': 'no-referrer', + }, + }) + }) + + it('accepts the cookie for index serving and gives every unauthenticated request one response', async () => { + const auth = await createAuth(new RecordCredentials()) + const { cookie } = exchange(auth) + const allowed = response() + expect(auth.authorizeIndex(request('/index.html', '127.0.0.1:3080', { cookie }), allowed.value)).toBe(true) + expect(allowed.state).toEqual({}) + + for (const candidate of [ + request('/'), + request('/?token=wrong'), + request('/?token=wrong&token=again'), + request('/index.html?token=wrong'), + request(auth.authenticatedUrl('http://127.0.0.1:3080'), '127.0.0.1:3080', { method: 'HEAD' }), + ]) { + const denied = response() + expect(auth.authorizeIndex(candidate, denied.value)).toBe(false) + expect(denied.state.status).toBe(401) + expect(denied.state.headers).toEqual({ + 'cache-control': 'no-store', + 'content-type': 'text/plain; charset=utf-8', + }) + expect(denied.state.body).toBe(candidate.method === 'HEAD' + ? undefined + : 'dsh web authentication required; reopen the URL printed by dsh web.\n') + } + }) + + it('rejects tampering, expiry, future issuance, and a longer lifetime than configured', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-08-24T00:00:00.000Z')) + const store = new RecordCredentials() + const auth = await createAuth(store) + const { cookie } = exchange(auth) + const [name, value] = cookie.split('=') as [string, string] + + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=broken` }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=${value.slice(0, -1)}x` }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: `${name}=%` }))).toBe(false) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { + cookie: signedBodyCookie(store, name, 'a'), + }))).toBe(false) + expect(auth.isAuthenticated({ headers: {} })).toBe(false) + expect(auth.isAuthenticated({ headers: { host: 'bad host', cookie } })).toBe(false) + expect(auth.isAuthenticated({ headers: { host: '127.0.0.1:3080' } })).toBe(false) + + const invalidPayloads: unknown[] = [ + 'not json', + null, + { version: 2, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: Date.now() + 1000 }, + { version: 1, authority: 42, issuedAt: Date.now(), expiresAt: Date.now() + 1000 }, + { version: 1, authority: '127.0.0.1:3080', issuedAt: 'now', expiresAt: Date.now() + 1000 }, + { version: 1, authority: '127.0.0.1:3080', issuedAt: Date.now(), expiresAt: 'later' }, + ] + for (const payload of invalidPayloads) { + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { + cookie: signedCookie(store, name, payload), + }))).toBe(false) + } + + const shorter = await createAuth(store, 1) + expect(shorter.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + vi.setSystemTime(new Date('2026-09-24T00:00:00.000Z')) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + vi.setSystemTime(new Date('2026-08-23T00:00:00.000Z')) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie }))).toBe(false) + }) + + it('loads one secret per activation and replaces it after deletion on the next activation', async () => { + const store = new RecordCredentials() + const auth = await createAuth(store) + const first = exchange(auth) + expect(store).toMatchObject({ reads: 0, modifies: 1 }) + + await store.deleteRecord() + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(true) + const sameActivation = exchange(auth) + expect(auth.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: sameActivation.cookie }))).toBe(true) + expect(store).toMatchObject({ reads: 0, modifies: 1 }) + + const reactivated = await createAuth(store) + const second = exchange(reactivated) + expect(second.cookie).not.toBe(first.cookie) + expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: first.cookie }))).toBe(false) + expect(reactivated.isAuthenticated(request('/', '127.0.0.1:3080', { cookie: second.cookie }))).toBe(true) + expect(store).toMatchObject({ reads: 0, modifies: 2 }) + }) + + it('fails loud on an invalid owner record instead of replacing it', async () => { + const unsupported = new RecordCredentials() + unsupported.record = { kind: 'api-key', key: 'not-a-cookie-secret' } + await expect(createAuth(unsupported)).rejects.toThrow(/unsupported format/u) + + const malformed = new RecordCredentials() + malformed.record = { kind: 'grant', payload: { version: 1, secret: 'short' } } + await expect(createAuth(malformed)).rejects.toThrow(/invalid secret/u) + + const nonString = new RecordCredentials() + nonString.record = { kind: 'grant', payload: { version: 1, secret: 42 } } + await expect(createAuth(nonString)).rejects.toThrow(/invalid secret/u) + + const discarded = new RecordCredentials() + discarded.discardWrites = true + await expect(createAuth(discarded)).rejects.toThrow(/was not created/u) + + await expect(createAuth(new RecordCredentials(), Number.MAX_SAFE_INTEGER)) + .rejects.toThrow(/safe timestamp range/u) + }) +}) diff --git a/packages/client/connection/tests/browser-credentials.ts b/packages/client/connection/tests/browser-credentials.ts new file mode 100644 index 0000000000..3739101648 --- /dev/null +++ b/packages/client/connection/tests/browser-credentials.ts @@ -0,0 +1,36 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { CredentialProvider, CredentialRecord } from '@deepseek-ai/dsh-credentials' + +/** Mutable credential-record double for Connection authentication tests. */ +export class RecordCredentials { + record: CredentialRecord | undefined + discardWrites = false + reads = 0 + modifies = 0 + + readRecord(): Promise { + this.reads += 1 + return Promise.resolve(this.record) + } + + async modifyRecord( + _key: unknown, + mutate: (current: CredentialRecord | undefined) => Promise, + ): Promise { + this.modifies += 1 + const next = await mutate(this.record) + if (this.discardWrites) return undefined + if (next !== undefined) this.record = next + return this.record + } + + deleteRecord(): Promise { + this.record = undefined + return Promise.resolve() + } +} + +/** Provide the record operations Connection needs during authentication setup. */ +export function provideBrowserCredentials(ctx: Context): void { + ctx.provide('credentials', new RecordCredentials() as unknown as CredentialProvider) +} diff --git a/packages/client/connection/tests/client-apply.client.spec.ts b/packages/client/connection/tests/client-apply.client.spec.ts index b7f6ebe389..443d398eba 100644 --- a/packages/client/connection/tests/client-apply.client.spec.ts +++ b/packages/client/connection/tests/client-apply.client.spec.ts @@ -1,59 +1,57 @@ /** * Connection plugin browser-half apply: ctx.connection handle mounting, mode - * selection off the page URL, and the single-consumer stream-loop ownership. + * selection off the page URL, and single-consumer connection-loop ownership. */ import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' -import { apply, type ConnectionHandle } from '../src/client/index.ts' -import type { RpcMessage } from '../src/client/api.ts' -import { RpcId } from '../src/client/api.ts' +import { + apply, + type ClientTransportHooks, + type ConnectionGenerationSource, + type ConnectionHandle, +} from '../src/client/index.ts' import { FixtureApiClient } from '../src/client/fixture.ts' import { WebApiClient } from '../src/client/web-api-client.ts' -type Win = { location?: { hostname: string; search: string; origin?: string } } -type WebSocketGlobal = { WebSocket?: typeof WebSocket } - -const originalWebSocket = globalThis.WebSocket -const sockets: FakeWebSocket[] = [] - -class FakeWebSocket extends EventTarget { - static readonly CONNECTING = 0 - static readonly OPEN = 1 - static readonly CLOSING = 2 - static readonly CLOSED = 3 - - readonly url: string - readyState = FakeWebSocket.CONNECTING - - constructor(url: string | URL) { - super() - this.url = String(url) - sockets.push(this) - queueMicrotask(() => { - if (this.readyState !== FakeWebSocket.CONNECTING) return - this.readyState = FakeWebSocket.OPEN - this.dispatchEvent(new Event('open')) - }) - } - - close(): void { - if (this.readyState === FakeWebSocket.CLOSED) return - this.readyState = FakeWebSocket.CLOSED - this.dispatchEvent(new Event('close')) - } - - receive(data: unknown): void { - this.dispatchEvent(new MessageEvent('message', { data })) - } +type Win = { + location?: { hostname: string; search: string; origin?: string } + __DSH_TRANSPORT__?: ClientTransportHooks } afterEach(() => { delete (globalThis as Win).location - sockets.length = 0 - if (originalWebSocket === undefined) delete (globalThis as WebSocketGlobal).WebSocket - else globalThis.WebSocket = originalWebSocket + delete (globalThis as Win).__DSH_TRANSPORT__ }) +class GenerationProbe { + private readonly active = new Set<() => void>() + + readonly source: ConnectionGenerationSource = (signal, ready) => new Promise((resolve) => { + let settled = false + const finish = (): void => { + if (settled) return + settled = true + signal.removeEventListener('abort', finish) + this.active.delete(finish) + resolve() + } + this.active.add(finish) + signal.addEventListener('abort', finish, { once: true }) + ready() + if (signal.aborted) finish() + }) + + end(): void { + for (const finish of [...this.active]) finish() + } +} + +function installGeneration(handle: ConnectionHandle): GenerationProbe { + const probe = new GenerationProbe() + handle.registerGenerationSource(probe.source) + return probe +} + async function mount(): Promise { const ctx = new Context() await ctx.plugin({ apply, inject: [] }) @@ -84,9 +82,33 @@ describe('connection client apply', () => { expect((await mount()).isLoopback).toBe(false) }) - it('start() hands out one loop, rejects a second consumer, and stop() aborts the streams', async () => { + it('requires one generation source and ignores a stale source disposer', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + const first = new GenerationProbe() + const second = new GenerationProbe() + + expect(() => handle.start({})).toThrow('no generation source is registered') + const unregisterFirst = handle.registerGenerationSource(first.source) + expect(() => { handle.registerGenerationSource(second.source) }) + .toThrow('a generation source is already registered') + unregisterFirst() + const unregisterSecond = handle.registerGenerationSource(second.source) + unregisterFirst() + + const loop = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + unregisterSecond() + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + loop.stop() + }) + + it('start() hands out one loop, rejects a second consumer, and stop() aborts the generation', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + installGeneration(handle) const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) const descriptions: Array = [] const stopThrowing = handle.hostDescription.subscribe(() => { throw new Error('subscriber bug') }) @@ -111,9 +133,33 @@ describe('connection client apply', () => { errorSpy.mockRestore() }) + it('allows a replacement owner and ignores the previous owner handle', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const generation = installGeneration(handle) + + const first = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + first.stop() + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + + const second = handle.start({}) + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + first.stop() + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + + second.stop() + generation.end() + }) + it('does not announce a generation synchronously stopped by a description subscriber', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + installGeneration(handle) const owner: { loop?: ReturnType } = {} let sawDescription = false const stopDescription = handle.hostDescription.subscribe(() => { @@ -137,6 +183,7 @@ describe('connection client apply', () => { it('retracts the host description while reconnecting and republishes the next generation', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } const handle = await mount() + const generation = installGeneration(handle) const descriptions: Array = [] const reconnectSnapshots: Array = [] const stopDescription = handle.hostDescription.subscribe(() => { @@ -149,16 +196,12 @@ describe('connection client apply', () => { reconnectSnapshots.push(handle.hostDescription.getSnapshot()?.canOpenPath) } }, - }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 }) + }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) try { await vi.waitFor(() => { expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) }) - const timing = (globalThis as Record).__fxTiming as - | { breakStreams(): void } - | undefined - if (timing === undefined) throw new Error('fixture timing hooks missing') - timing.breakStreams() + generation.end() await vi.waitFor(() => { expect(reconnectSnapshots).toEqual([undefined]) }) await vi.waitFor(() => { expect(descriptions).toEqual([true, undefined, true]) }) @@ -170,7 +213,40 @@ describe('connection client apply', () => { } }) - it('WebApiClient keeps unary calls and respond on globalThis.fetch', async () => { + it('does not announce reconnecting after a description subscriber stops the loop', async () => { + ;(globalThis as Win).location = { hostname: 'localhost', search: '?fixture' } + const handle = await mount() + const generation = installGeneration(handle) + const owner: { loop?: ReturnType } = {} + let stoppedOnRetraction = false + const stopDescription = handle.hostDescription.subscribe(() => { + if (handle.hostDescription.getSnapshot() !== undefined || owner.loop === undefined) return + stoppedOnRetraction = true + owner.loop.stop() + }) + const states: string[] = [] + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const loop = handle.start({ + onStateChange: (state) => { states.push(state) }, + }, { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 }) + owner.loop = loop + try { + await vi.waitFor(() => { + expect(handle.hostDescription.getSnapshot()?.canOpenPath).toBe(true) + }) + generation.end() + + await vi.waitFor(() => { expect(stoppedOnRetraction).toBe(true) }) + expect(handle.hostDescription.getSnapshot()).toBeUndefined() + expect(states).toEqual(['connected']) + } finally { + stopDescription() + loop.stop() + warnSpy.mockRestore() + } + }) + + it('WebApiClient keeps unary calls on globalThis.fetch', async () => { ;(globalThis as Win).location = { hostname: 'localhost', search: '' } const handle = await mount() const original = globalThis.fetch @@ -182,103 +258,10 @@ describe('connection client apply', () => { try { // Schema rejection is fine — the transport hop is the assertion. await (handle.api as WebApiClient).host.describe({}).catch(() => undefined) - await handle.api.respond({ - type: 'client-response', - rpcId: RpcId('response-over-http'), - result: { ok: true, value: {} }, - }).catch(() => undefined) } finally { globalThis.fetch = original } expect(seen.some(u => u.includes('/api/host.describe'))).toBe(true) - expect(seen.some(u => u.includes('/api/respond'))).toBe(true) - }) - - it('opens one WebSocket per downlink, parses frames, and aborts both without using fetch', async () => { - ;(globalThis as Win).location = { - hostname: 'localhost', search: '', origin: 'http://localhost:3080', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const fetch = vi.spyOn(globalThis, 'fetch') - const client = (await mount()).api as WebApiClient - const envelopes: RpcMessage[][] = [] - client.subscribeEnvelopes((batch) => { envelopes.push([...batch]) }) - const opened: string[] = [] - const muxAbort = new AbortController() - const hostAbort = new AbortController() - const mux = client.events.mux({}, muxAbort.signal, () => { opened.push('mux') })[Symbol.asyncIterator]() - const host = client.events.host({}, hostAbort.signal, () => { opened.push('host') })[Symbol.asyncIterator]() - const muxFrame = mux.next() - const hostFrame = host.next() - await vi.waitFor(() => { expect(sockets).toHaveLength(2) }) - expect(sockets.map(socket => socket.url)).toEqual([ - 'ws://localhost:3080/api/events.mux', - 'ws://localhost:3080/api/events.host', - ]) - await vi.waitFor(() => { expect(opened).toEqual(['mux', 'host']) }) - - const errors = vi.spyOn(console, 'error').mockImplementation(() => {}) - sockets[0]!.receive(new Uint8Array([1, 2, 3])) - sockets[1]!.receive(JSON.stringify({ type: 'server-request', rpcId: 'bad', method: 'host/session-status', payload: {} })) - sockets[0]!.receive(JSON.stringify({ - type: 'server-request', - rpcId: 'mux-browser', - method: 'session/subscribed', - payload: { type: 'session/subscribed', sessionId: 'session-browser', lastSeq: 8 }, - })) - sockets[1]!.receive(JSON.stringify({ - type: 'server-request', - rpcId: 'host-browser', - method: 'host/remote-event', - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - })) - expect(await muxFrame).toMatchObject({ - value: { rpcId: 'mux-browser', payload: { type: 'session/subscribed', lastSeq: 8 } }, - }) - expect(await hostFrame).toMatchObject({ - value: { rpcId: 'host-browser', payload: { type: 'host/remote-event', event: 'commands/change' } }, - }) - expect(errors).toHaveBeenCalledTimes(2) - await vi.waitFor(() => { expect(envelopes.flat()).toHaveLength(2) }) - expect(fetch).not.toHaveBeenCalled() - - const muxEnd = mux.next() - const hostEnd = host.next() - muxAbort.abort() - hostAbort.abort() - await expect(muxEnd).resolves.toMatchObject({ done: true }) - await expect(hostEnd).resolves.toMatchObject({ done: true }) - expect(sockets.every(socket => socket.readyState === FakeWebSocket.CLOSED)).toBe(true) - errors.mockRestore() - fetch.mockRestore() - }) - - it('maps an HTTPS page origin to a secure WebSocket URL', async () => { - ;(globalThis as Win).location = { - hostname: 'harness.example', search: '', origin: 'https://harness.example', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const client = (await mount()).api - const abort = new AbortController() - const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() - const pending = iterator.next() - await vi.waitFor(() => { expect(sockets[0]?.url).toBe('wss://harness.example/api/events.mux') }) - abort.abort() - await expect(pending).resolves.toMatchObject({ done: true }) - }) - - it('closes a WebSocket immediately when its signal was already aborted', async () => { - ;(globalThis as Win).location = { - hostname: 'localhost', search: '', origin: 'http://localhost:3080', - } - ;(globalThis as WebSocketGlobal).WebSocket = FakeWebSocket as unknown as typeof WebSocket - const client = (await mount()).api - const abort = new AbortController() - abort.abort() - const iterator = client.events.mux({}, abort.signal)[Symbol.asyncIterator]() - await expect(iterator.next()).resolves.toMatchObject({ done: true }) - expect(sockets).toHaveLength(1) - expect(sockets[0]?.readyState).toBe(FakeWebSocket.CLOSED) }) it('carries RPC calls without requiring secure-context randomUUID', async () => { @@ -319,6 +302,44 @@ describe('connection client apply', () => { }) }) + it('exposes a worker-local Gateway stream through connection.rpc.open', async () => { + ;(globalThis as Win).location = { hostname: 'preview.example', search: '' } + const openStream = vi.fn>( + (endpoint, payload, signal) => (async function *(): AsyncGenerator { + signal.throwIfAborted() + yield { endpoint, payload } + })(), + ) + ;(globalThis as Win).__DSH_TRANSPORT__ = { + createApiClient: () => new FixtureApiClient(), + fetch: vi.fn(), + openStream, + ownsHost: true, + } + const handle = await mount() + const abort = new AbortController() + const open = handle.rpc.open + if (open === undefined) throw new Error('worker-local stream carrier was not installed') + + const values = [] + for await (const value of open('/api', 'session/follow', { args: { sessionId: 'session-1' } }, abort.signal)) { + values.push(value) + } + expect(values).toEqual([{ + endpoint: 'session/follow', payload: { args: { sessionId: 'session-1' } }, + }]) + expect(openStream).toHaveBeenCalledWith( + 'session/follow', + { args: { sessionId: 'session-1' } }, + abort.signal, + ) + expect(handle.isLoopback).toBe(true) + expect(() => open('/rpc', 'session/follow', {}, abort.signal)) + .toThrow('worker-local streams require the /api channel') + expect(() => open('/api/path', 'session/follow', {}, abort.signal)) + .toThrow('invalid RPC target') + }) + it('validates generic RPC transport failures, correlation, and targets', async () => { ;(globalThis as Win).location = { hostname: 'harness.example', search: '', origin: 'https://harness.example', @@ -345,6 +366,51 @@ describe('connection client apply', () => { const fetch = vi.mocked(globalThis.fetch) expect(fetch.mock.calls[0]?.[0]).toEqual(new URL('http://dsh.internal/api/goals/create')) expect(fetch.mock.calls[0]?.[1]).not.toHaveProperty('signal') + + const respond = (result: unknown): void => { + globalThis.fetch = async (_input: URL | RequestInfo, init?: RequestInit) => { + if (typeof init?.body !== 'string') throw new TypeError('expected a JSON request body') + const request = JSON.parse(init.body) as { rpcId: string } + return Response.json({ type: 'server-response', rpcId: request.rpcId, result }) + } + } + for (const envelope of [ + null, + { type: 'other', rpcId: 'rpc', result: { ok: true } }, + { type: 'server-response', rpcId: 1, result: { ok: true } }, + ]) { + globalThis.fetch = vi.fn().mockResolvedValue(Response.json(envelope)) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response envelope') + } + + respond(null) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + respond({ ok: 'yes' }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + respond({ ok: false, error: null }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response result') + + for (const error of [ + { code: 1, message: 'failed', details: {} }, + { code: 'failed', message: 1, details: {} }, + { code: 'failed', message: 'failed', details: [] }, + ]) { + respond({ ok: false, error }) + await expect(handle.rpc.call('/api', 'goals/create', {})) + .rejects.toThrow('invalid server-response failure') + } + respond({ + ok: false, + error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } }, + }) + await expect(handle.rpc.call('/api', 'goals/create', {})).resolves.toEqual({ + ok: false, + error: { code: 'fixture-failed', message: 'fixture rejected the call', details: { retry: false } }, + }) } finally { globalThis.fetch = original } diff --git a/packages/client/connection/tests/connection.client.spec.ts b/packages/client/connection/tests/connection.client.spec.ts index 7965d627f4..03f57f9273 100644 --- a/packages/client/connection/tests/connection.client.spec.ts +++ b/packages/client/connection/tests/connection.client.spec.ts @@ -1,32 +1,24 @@ /** - * ConnectionController: stream pumping into sinks, the strict readiness - * handshake (describe + both streams' onOpen, timeout-guarded), generation + * ConnectionController: strict readiness handshake (describe + incremental + * source ready), generation * abort on loss, backoff reconnection, state transitions, and sink-exception * isolation. Real (short) timers — the timeout and backoff are configurable, * so tests run them at millisecond scale. */ import { describe, expect, it, vi } from 'vitest' -import type { SessionId } from '../src/client/api.ts' import type { ConnectionState } from '../src/client/connection.ts' import { ConnectionController } from '../src/client/connection.ts' import { FakeApiClient, deferred, ok } from './fake-api.client.ts' -const SID = 'fk-c1' as SessionId -const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, streamOpenTimeoutMs: 500 } - -function subscribedFrame(lastSeq = 0) { - return { type: 'session/subscribed', sessionId: SID, lastSeq } as const -} +const FAST = { backoffBaseMs: 10, backoffFactor: 1, backoffMaxMs: 10, generationReadyTimeoutMs: 500 } describe('connection lifecycle', () => { - it('announces connected after describe + both streams open, then pumps frames to sinks', async () => { + it('announces connected after describe plus generation readiness', async () => { const api = new FakeApiClient() - const muxSeen: string[] = [] const descriptions: boolean[] = [] let connected = 0 - const controller = new ConnectionController(api, { - onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), + const controller = new ConnectionController(api, api.generation, { onConnected: (description) => { connected++ descriptions.push(description.canOpenPath) @@ -35,8 +27,6 @@ describe('connection lifecycle', () => { controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux(subscribedFrame()) - await vi.waitFor(() => { expect(muxSeen).toEqual(['session/subscribed']) }) expect(api.callsOf('host.describe')).toHaveLength(1) expect(descriptions).toEqual([true]) } finally { @@ -44,25 +34,25 @@ describe('connection lifecycle', () => { } }) - it('reconnects with a fresh generation when a stream fails, and stop() ends the loop', async () => { + it('reconnects with a fresh generation when its source fails, and stop() ends the loop', async () => { const api = new FakeApiClient() let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) api.failStreams(new Error('stream torn')) await vi.waitFor(() => { expect(connected).toBe(2) }) // new generation after backoff - expect(api.openMuxCount).toBe(1) // the dead generation's stream is gone, exactly one live + expect(api.openGenerationCount).toBe(1) } finally { controller.stop() warnSpy.mockRestore() } - // stop() aborts the live generation (streams tear down) and no reconnect follows. - await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) }) + // stop() aborts the live generation and no reconnect follows. + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) await new Promise(resolve => setTimeout(resolve, 40)) - expect(api.openMuxCount).toBe(0) + expect(api.openGenerationCount).toBe(0) }) it('treats describe failure as generation failure and retries', async () => { @@ -75,7 +65,7 @@ describe('connection lifecycle', () => { } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) // retried after backoff @@ -106,7 +96,7 @@ describe('connection lifecycle', () => { } let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(describeCalls).toBe(2) }) @@ -117,70 +107,45 @@ describe('connection lifecycle', () => { } }) - it('converges stream/error frames into reconnect instead of dispatching them', async () => { + it('isolates a connected sink exception from the generation', async () => { const api = new FakeApiClient() - const muxSeen: string[] = [] - let connected = 0 - const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { - onMuxEnvelope: envelope => muxSeen.push(envelope.payload.type), - onConnected: () => { connected++ }, - }, FAST) - controller.start() - try { - await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux({ type: 'stream/error', error: { code: 'internal', message: 'impl broke', details: {} } }) - await vi.waitFor(() => { expect(connected).toBe(2) }) // treated as loss → reconnect - expect(muxSeen).toEqual([]) // never forwarded to the business sink - } finally { - controller.stop() - warnSpy.mockRestore() - } - }) - - it('isolates sink exceptions from the pump', async () => { - const api = new FakeApiClient() - const seen: string[] = [] let connected = 0 const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { - onMuxEnvelope: (envelope) => { - seen.push(envelope.payload.type) + const controller = new ConnectionController(api, api.generation, { + onConnected: () => { + connected++ throw new Error('business layer bug') }, - onConnected: () => { connected++ }, }, FAST) controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - api.pushMux(subscribedFrame(1)) - api.pushMux(subscribedFrame(2)) - await vi.waitFor(() => { expect(seen).toHaveLength(2) }) // second frame still pumped - expect(connected).toBe(1) // no reconnect triggered by the sink throw + expect(api.openGenerationCount).toBe(1) + expect(errorSpy).toHaveBeenCalledWith('[connection] connection sink threw:', expect.any(Error)) } finally { controller.stop() errorSpy.mockRestore() } }) - it('holds onConnected until both streams establish even after describe succeeds', async () => { + it('holds onConnected until the incremental source is ready after describe succeeds', async () => { const api = new FakeApiClient() - api.holdStreamOpen = true // describe resolves immediately; stream establishment is in the case's hand + api.holdGenerationReady = true let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() try { await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) await new Promise(resolve => setTimeout(resolve, 30)) expect(connected).toBe(0) // describe alone must not announce - api.releaseStreamOpens() + api.releaseGenerationReady() await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() } }) - it('rejects a generation whose streams end during readiness and retries', async () => { + it('rejects a generation whose source ends during readiness and retries', async () => { const api = new FakeApiClient() const firstDescribe = deferred>>() let describeCalls = 0 @@ -193,13 +158,13 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) controller.start() try { - await vi.waitFor(() => { expect(api.openMuxCount).toBe(1) }) + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(1) }) api.endStreams() firstDescribe.resolve(ok({ version: '0', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true })) @@ -212,16 +177,54 @@ describe('connection lifecycle', () => { } }) - it('proceeds as connected via the timeout guard when a carrier never fires onOpen', async () => { + it.each([ + { label: 'ends normally', fail: () => Promise.resolve() }, + { + label: 'rejects with a non-Error reason', + // oxlint-disable-next-line typescript/prefer-promise-reject-errors -- non-Error source normalization is the scenario. + fail: () => Promise.reject('fixture offline'), + }, + ])('retries when the generation source $label before reporting ready', async ({ fail }) => { const api = new FakeApiClient() - api.suppressStreamOpen = true // misbehaving carrier: streams open but onOpen never fires + let sourceCalls = 0 let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, { ...FAST, streamOpenTimeoutMs: 20 }) + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController(api, (signal, ready) => { + sourceCalls++ + if (sourceCalls === 1) return fail() + ready() + return new Promise((resolve) => { + signal.addEventListener('abort', () => { resolve() }, { once: true }) + }) + }, { onConnected: () => { connected++ } }, FAST) controller.start() try { - await vi.waitFor(() => { expect(connected).toBe(1) }) // handshake resolved by the guard, not wedged + await vi.waitFor(() => { expect(sourceCalls).toBe(2) }) + await vi.waitFor(() => { expect(connected).toBe(1) }) } finally { controller.stop() + warnSpy.mockRestore() + } + }) + + it('rejects and retries a generation whose source never reports ready', async () => { + const api = new FakeApiClient() + api.suppressGenerationReady = true + let connected = 0 + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + const controller = new ConnectionController( + api, + api.generation, + { onConnected: () => { connected++ } }, + { ...FAST, generationReadyTimeoutMs: 20 }, + ) + controller.start() + try { + await vi.waitFor(() => { expect(api.callsOf('host.describe').length).toBeGreaterThan(1) }) + expect(connected).toBe(0) + } finally { + controller.stop() + warnSpy.mockRestore() } }) @@ -230,7 +233,7 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -251,7 +254,7 @@ describe('connection lifecycle', () => { const api = new FakeApiClient() const states: ConnectionState[] = [] let connected = 0 - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: (state) => { states.push(state) @@ -261,7 +264,7 @@ describe('connection lifecycle', () => { controller.start() await vi.waitFor(() => { expect(states).toEqual(['connected']) }) - await vi.waitFor(() => { expect(api.openMuxCount).toBe(0) }) + await vi.waitFor(() => { expect(api.openGenerationCount).toBe(0) }) expect(connected).toBe(0) }) @@ -276,7 +279,7 @@ describe('connection lifecycle', () => { const states: ConnectionState[] = [] let connected = 0 const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) - const controller = new ConnectionController(api, { + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ }, onStateChange: state => states.push(state), }, FAST) @@ -294,11 +297,10 @@ describe('connection lifecycle', () => { it('runs with no sinks at all (every callback slot optional)', async () => { const api = new FakeApiClient() - const controller = new ConnectionController(api, {}, FAST) + const controller = new ConnectionController(api, api.generation, {}, FAST) controller.start() try { await vi.waitFor(() => { expect(api.callsOf('host.describe')).toHaveLength(1) }) - api.pushMux(subscribedFrame()) // pumped with sink undefined: dropped silently await new Promise(resolve => setTimeout(resolve, 20)) } finally { controller.stop() @@ -308,12 +310,12 @@ describe('connection lifecycle', () => { it('start() is idempotent (one loop, one stream set)', async () => { const api = new FakeApiClient() let connected = 0 - const controller = new ConnectionController(api, { onConnected: () => { connected++ } }, FAST) + const controller = new ConnectionController(api, api.generation, { onConnected: () => { connected++ } }, FAST) controller.start() controller.start() try { await vi.waitFor(() => { expect(connected).toBe(1) }) - expect(api.openMuxCount).toBe(1) + expect(api.openGenerationCount).toBe(1) expect(api.callsOf('host.describe')).toHaveLength(1) } finally { controller.stop() diff --git a/packages/client/connection/tests/fake-api.client.ts b/packages/client/connection/tests/fake-api.client.ts index 7c9dc6accb..93ab3af3b0 100644 --- a/packages/client/connection/tests/fake-api.client.ts +++ b/packages/client/connection/tests/fake-api.client.ts @@ -1,10 +1,8 @@ // Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and -// deferred-controlled timing). Streams are hand pumps: pushMux/pushHost. -import type { - HostFrame, IApiClient, ModelSelection, MuxFrame, - RpcRequest, RpcResponse, SessionId, SessionModels, SessionSearchItem, SkillEntry, WorkspaceId, -} from '../src/client/api.ts' +// deferred-controlled timing). The generation source is a hand pump. +import type { IApiClient, RpcResponse, SkillEntry } from '../src/client/api.ts' +import type { ConnectionGenerationSource } from '../src/client/connection.ts' import { RpcId } from '../src/client/api.ts' export interface Deferred { @@ -31,10 +29,10 @@ export function ok(value: T): RpcResponse { } -type StreamItem = { kind: 'frame'; envelope: RpcRequest } | { kind: 'end' } | { kind: 'fail'; error: unknown } +type StreamItem = { kind: 'end' } | { kind: 'fail'; error: unknown } -interface StreamConn { - feed(item: StreamItem): void +interface StreamConn { + feed(item: StreamItem): void } export class FakeApiClient implements IApiClient { @@ -42,34 +40,6 @@ export class FakeApiClient implements IApiClient { readonly calls: { method: string; payload: unknown }[] = [] // Programmable slots (defaults answer OK-empty); reassign per case. - onList: (payload: unknown) => Promise> = () => Promise.resolve(ok({ items: [] })) - onSearch: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ items: [], hasMore: false })) - onCreate: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-new' as SessionId })) - onRename: (payload: unknown) => Promise> = () => Promise.resolve(ok({ title: 'fk-renamed', seq: 0 })) - onFork: (payload: unknown) => Promise> = () => Promise.resolve(ok({ sessionId: 'fk-fork' as SessionId })) - onHistory: (payload: { sessionId: SessionId; beforeSeq?: number; maxMessages?: number }) - => Promise> = - () => Promise.resolve(ok({ - events: [], - hasMore: false, - modelSelection: { provider: 'deepseek-official', model: 'deepseek-chat' }, - })) - - onModels: (payload: unknown) => Promise> = () => Promise.resolve(ok({ - current: { provider: 'deepseek-official', model: 'deepseek-chat' }, - routable: true, - groups: [], - failures: [], - })) - onSelectModel: (payload: ModelSelection & { 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==' })) - onUpdateQueue: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) - onCancel: (payload: unknown) => Promise> = () => Promise.resolve(ok({ accepted: true as const })) onDescribe: (payload: unknown) => Promise Promise> = () => Promise.resolve(ok({ path: '/home/fake/new' })) - private readonly muxConns: StreamConn[] = [] - private readonly hostConns: StreamConn[] = [] - lastSearchSignal: AbortSignal | undefined - - // Parameter annotations below are local structural types on purpose: the CI - // lint lane runs without built artifacts, where IApiClient's wire types - // (apiproxy subpath) resolve to any and inferred params trip no-unsafe-argument. - readonly sessions: IApiClient['sessions'] = { - list: (payload: unknown) => this.record('session.list', payload, this.onList(payload)), - search: (payload: unknown, signal?: AbortSignal) => { - this.lastSearchSignal = signal - return this.record('session.search', payload, this.onSearch(payload)) - }, - 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: ModelSelection & { sessionId: SessionId }) => - this.record('session.selectModel', payload, this.onSelectModel(payload)), - rename: (payload: unknown) => this.record('session.rename', payload, this.onRename(payload)), - fork: (payload: unknown) => this.record('session.fork', payload, this.onFork(payload)), - prompt: (payload: unknown) => this.record('session.prompt', payload, this.onPrompt(payload)), - attachment: (payload: unknown) => this.record('session.attachment', payload, this.onAttachment(payload)), - updateQueue: (payload: unknown) => this.record('session.updateQueue', payload, this.onUpdateQueue(payload)), - cancel: (payload: unknown) => this.record('session.cancel', payload, this.onCancel(payload)), - } - - readonly subagents: IApiClient['subagents'] = { - list: (payload: unknown) => this.record('subagent.list', payload, Promise.resolve(ok({ - entries: [], - parentAvailable: true, - }))), - history: (payload: unknown) => this.record('subagent.history', payload, Promise.resolve(ok({ - events: [], - hasMore: false, - }))), - prompt: (payload: unknown) => this.record('subagent.prompt', payload, Promise.resolve(ok({ - messageId: 'fake-message' as never, - }))), - interrupt: (payload: unknown) => this.record('subagent.interrupt', payload, Promise.resolve(ok({ - accepted: true as const, - }))), - } + private readonly generationConns: StreamConn[] = [] readonly host: IApiClient['host'] = { describe: payload => this.record('host.describe', payload, this.onDescribe(payload)), @@ -149,27 +77,6 @@ export class FakeApiClient implements IApiClient { openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)), } - readonly workspace: IApiClient['workspace'] = { - list: (payload: unknown) => this.record('workspace.list', payload, Promise.resolve(ok({ items: [], archivedSessionIds: [] }))), - create: (payload: unknown) => this.record('workspace.create', payload, Promise.resolve(ok({ - workspace: { workspaceId: 'fk-ws' as never, path: '/f/ws', title: 'ws', sessionIds: [], createdAt: '0', updatedAt: '0' }, - created: true, - }))), - 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 }))), - insertBefore: (payload: unknown) => this.record('workspace.insertBefore', payload, Promise.resolve(ok({ - workspaceIds: [(payload as { workspaceId: WorkspaceId }).workspaceId], - }))), - 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' }, - }))), - archiveSession: (payload: unknown) => this.record('workspace.archiveSession', payload, Promise.resolve(ok({ - archivedSessionIds: [(payload as { sessionId: SessionId }).sessionId], - }))), - } - // Payloads stay `unknown` (lint-lane note above); response rows are the real // wire shapes so cases can program catalogs and skill lists without casts. onSkillList: (payload: unknown) => Promise> @@ -177,34 +84,14 @@ export class FakeApiClient implements IApiClient { readonly agentPresets: IApiClient['agentPresets'] = { - list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [], authorable: false, hasDocument: false }))), - select: (payload: { agentPreset: string }) => - this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), - read: (payload: { agentPreset: string }) => - this.record('agentPreset.read', payload, Promise.resolve(ok({ - agentPreset: payload.agentPreset, trust: 'user' as const, content: '', - }))), - copy: (payload: { agentPreset: string }) => - this.record('agentPreset.copy', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))), openDocument: (payload: { agentPreset: string }) => this.record('agentPreset.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), - remove: (payload: { agentPreset: string }) => - this.record('agentPreset.remove', payload, Promise.resolve(ok({}))), } readonly skills: IApiClient['skills'] = { list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)), } - readonly goals: IApiClient['goals'] = { - create: payload => this.record('goal.create', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - edit: payload => this.record('goal.edit', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - pause: payload => this.record('goal.pause', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - resume: payload => this.record('goal.resume', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - complete: payload => this.record('goal.complete', payload, Promise.resolve(ok({ ref: { id: 'fake-goal' as never, revision: 1 } }))), - clear: payload => this.record('goal.clear', payload, Promise.resolve(ok({ cleared: true as const }))), - } - readonly settings: IApiClient['settings'] = { describe: payload => this.record('settings.describe', payload, Promise.resolve(ok({ writable: true, hasDocument: false, namespaces: [] }))), openDocument: payload => this.record('settings.openDocument', payload, Promise.resolve(ok({ opened: true as const }))), @@ -221,55 +108,42 @@ export class FakeApiClient implements IApiClient { readonly llm: IApiClient['llm'] = { providers: payload => this.record('llm.providers', payload, Promise.resolve(ok({ providers: [] }))), - models: payload => this.record('llm.models', payload, Promise.resolve(ok({ groups: [], failures: [] }))), + models: payload => this.record('llm.models', payload, Promise.resolve(ok({ + default: { provider: 'fixture', model: 'fixture' }, + routableProviders: [], + groups: [], + failures: [], + }))), discoverModels: payload => this.record('llm.discoverModels', payload, Promise.resolve(ok({ models: [] }))), } - /** When true, streams never fire onOpen (misbehaving-carrier material for the handshake timeout guard). */ - suppressStreamOpen = false + /** When true, the source never reports ready. */ + suppressGenerationReady = false - /** When true, onOpen callbacks are parked instead of fired; releaseStreamOpens() fires them. - * Lets a case hold the readiness handshake open (describe done, streams not yet "established"). */ - holdStreamOpen = false + /** When true, ready callbacks remain parked until the test releases them. */ + holdGenerationReady = false private heldOpens: (() => void)[] = [] - releaseStreamOpens(): void { + releaseGenerationReady(): void { const held = this.heldOpens this.heldOpens = [] for (const fire of held) fire() } - readonly events: IApiClient['events'] = { - mux: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => - this.openStream(this.muxConns, signal, onOpen), - host: (_payload: unknown, signal: AbortSignal, onOpen?: () => void) => - this.openStream(this.hostConns, signal, onOpen), - } - - respond(): Promise<{ accepted: false; reason: 'not-pending' }> { - return Promise.resolve({ accepted: false, reason: 'not-pending' }) - } - - /** Push one mux frame to every open mux stream (rpcId minted unless pinned by the case). */ - pushMux(frame: MuxFrame, rpcId?: string): void { - for (const conn of [...this.muxConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } - - pushHost(frame: HostFrame, rpcId?: string): void { - for (const conn of [...this.hostConns]) conn.feed({ kind: 'frame', envelope: { rpcId: RpcId(rpcId ?? `push-${nextRpc++}`), payload: frame } }) - } + readonly generation: ConnectionGenerationSource = (signal, ready) => + this.openGeneration(signal, ready) /** End (clean close) or fail (throw) every open stream — reconnect-path material. */ endStreams(): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'end' }) + for (const conn of [...this.generationConns]) conn.feed({ kind: 'end' }) } failStreams(error: unknown): void { - for (const conn of [...this.muxConns, ...this.hostConns]) conn.feed({ kind: 'fail', error }) + for (const conn of [...this.generationConns]) conn.feed({ kind: 'fail', error }) } - get openMuxCount(): number { - return this.muxConns.length + get openGenerationCount(): number { + return this.generationConns.length } callsOf(method: string): unknown[] { @@ -281,25 +155,24 @@ export class FakeApiClient implements IApiClient { return response } - private async *openStream(registry: StreamConn[], signal: AbortSignal, onOpen?: () => void): AsyncGenerator> { - const inbox: StreamItem[] = [] + private async openGeneration(signal: AbortSignal, onOpen: () => void): Promise { + const inbox: StreamItem[] = [] let wake: (() => void) | null = null - const conn: StreamConn = { + const conn: StreamConn = { feed: (item) => { inbox.push(item) wake?.() }, } - registry.push(conn) - if (this.holdStreamOpen && onOpen !== undefined) this.heldOpens.push(onOpen) - else if (!this.suppressStreamOpen) onOpen?.() + this.generationConns.push(conn) + if (this.holdGenerationReady) this.heldOpens.push(onOpen) + else if (!this.suppressGenerationReady) onOpen() try { while (!signal.aborted) { while (inbox.length > 0) { - const item = inbox.shift() as StreamItem + const item = inbox.shift() as StreamItem if (item.kind === 'end') return if (item.kind === 'fail') throw item.error - yield item.envelope } await new Promise((resolve) => { wake = resolve @@ -308,7 +181,7 @@ export class FakeApiClient implements IApiClient { wake = null } } finally { - registry.splice(registry.indexOf(conn), 1) + this.generationConns.splice(this.generationConns.indexOf(conn), 1) } } } diff --git a/packages/client/connection/tests/fixture-commands.client.spec.ts b/packages/client/connection/tests/fixture-commands.client.spec.ts index 62118062b5..163fb414a6 100644 --- a/packages/client/connection/tests/fixture-commands.client.spec.ts +++ b/packages/client/connection/tests/fixture-commands.client.spec.ts @@ -45,15 +45,18 @@ describe('createFixtureApi commands/skills', () => { expect(result).toMatchObject({ ok: false, error: { code: 'session-not-found' } }) }) - it('executes a known command line: pure admission plus a mux-broadcast lifecycle pair', async () => { - const { api, rpc } = createFixtureFaces() + it('executes a known command line: pure admission plus a followed lifecycle pair', async () => { + const { rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() - const stream = api.events.mux(req({}), abort.signal) + const stream = rpc.open?.('/api', 'session/follow', { + args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, + }, abort.signal) + if (stream === undefined) throw new Error('fixture session follow stream is unavailable') const pump = (async () => { for await (const frame of stream) { - frames.push(frame.payload) - if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + frames.push(frame) + if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() } })() const execution = await callRemote<{ commandId: string } | undefined>( @@ -61,7 +64,7 @@ describe('createFixtureApi commands/skills', () => { expect(execution?.commandId).toBeTruthy() await pump const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hello world', source: { kind: 'user' } } }, @@ -83,14 +86,17 @@ describe('createFixtureApi commands/skills', () => { }) it('refuses an image-carrying execute for a non-declaring command with a logged error pair', async () => { - const { api, rpc } = createFixtureFaces() + const { rpc } = createFixtureFaces() const frames: unknown[] = [] const abort = new AbortController() - const stream = api.events.mux(req({}), abort.signal) + const stream = rpc.open?.('/api', 'session/follow', { + args: { request: { address: { kind: 'session', sessionId: sid('fx-alpha') } } }, + }, abort.signal) + if (stream === undefined) throw new Error('fixture session follow stream is unavailable') const pump = (async () => { for await (const frame of stream) { - frames.push(frame.payload) - if (frames.filter(f => (f as { type: string }).type === 'session/event').length >= 2) abort.abort() + frames.push(frame) + if (frames.filter(f => (f as { type: string }).type === 'event').length >= 2) abort.abort() } })() const png = { mediaType: 'image/png', data: 'AA==' } @@ -100,7 +106,7 @@ describe('createFixtureApi commands/skills', () => { expect(refused?.result).toEqual({ kind: 'error', text: '/echo does not accept image attachments' }) await pump const events = frames - .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'session/event') + .filter((f): f is { type: string; event: { type: string; data: Record } } => (f as { type: string }).type === 'event') .map(f => f.event) expect(events).toMatchObject([ { type: 'command/run', data: { name: 'echo', args: ' hi', source: { kind: 'user' } } }, diff --git a/packages/client/connection/tests/fixture.client.spec.ts b/packages/client/connection/tests/fixture.client.spec.ts index ed5089d345..f17af5b978 100644 --- a/packages/client/connection/tests/fixture.client.spec.ts +++ b/packages/client/connection/tests/fixture.client.spec.ts @@ -1,19 +1,450 @@ -/** - * Fixture impl semantics: the demo data source must honor the same contract - * shapes as the real host (paging boundaries, rpcId echo, replay lifecycle, - * baseline replay, timing hooks) — this is the vitest-side drift detector for - * the hand-written fixture/host parallel implementations. - */ import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionId, WorkspaceId } from '../src/client/api.ts' +import type { + ModelSelection, + RpcMessage, + RpcRequest, + RpcResponse, + RpcResult, + SessionEvent, + SessionId, +} from '../src/client/api.ts' import { RpcId } from '../src/client/api.ts' -import type { HostFrame, MuxFrame, RpcMessage, RpcRequest } from '../src/client/api.ts' -import { FixtureApiClient, createFixtureApi } from '../src/client/fixture.ts' +import { decodeStorageRecord } from '@deepseek-ai/dsh-session/chunk-rows' +import type { ChunkRow } from '@deepseek-ai/dsh-session/chunk-rows' +import { + FixtureApiClient, + createFixtureFaces, + type FixtureOptions, +} from '../src/client/fixture.ts' +import type { + ClientConnectionRpc, +} from '../src/rpc.ts' const sid = (id: string): SessionId => id as SessionId +type WorkspaceId = string & { readonly __fixtureWorkspaceId: 'WorkspaceId' } const req =

(payload: P): RpcRequest

=> ({ rpcId: RpcId(`t-${Math.abs(Math.sin(reqCount++)).toString(36).slice(2, 10)}`), payload }) let reqCount = 0 +interface FixtureSessionSummary { + sessionId: SessionId + updatedAt: number + running: boolean + blank: boolean + parentSessionId?: SessionId + origin?: 'subagent' + cwd?: string + agentPreset?: string +} + +interface FixtureHistoryEntry { + readonly type: 'event' + readonly event: SessionEvent +} + +type FixtureChunkRowEvent = { + [Kind in ChunkRow['type']]: { + readonly type: `chunkrow/${Kind}` + readonly seq: number + readonly time: number + readonly data: Extract['data'] + } +}[ChunkRow['type']] + +interface FixtureHistoryChunkRun { + readonly type: 'chunks' + readonly event: FixtureChunkRowEvent +} + +type FixtureHistoryRecord = FixtureHistoryEntry | FixtureHistoryChunkRun + +interface FixturePage { + readonly records: readonly FixtureHistoryRecord[] + readonly hasMore: boolean +} + +function historyEvents(records: readonly FixtureHistoryRecord[]): SessionEvent[] { + return records.flatMap(record => record.type === 'event' + ? [record.event] + : decodeStorageRecord(chunkRow(record.event))) +} + +function chunkRow(event: FixtureChunkRowEvent): ChunkRow { + switch (event.type) { + case 'chunkrow/text-chunks': + return { type: 'text-chunks', seq0: event.seq, time0: event.time, data: event.data } + case 'chunkrow/reasoning-chunks': + return { type: 'reasoning-chunks', seq0: event.seq, time0: event.time, data: event.data } + case 'chunkrow/tool-call-chunks': + return { type: 'tool-call-chunks', seq0: event.seq, time0: event.time, data: event.data } + } +} + +type FixtureFollowFrame = + | { + readonly type: 'snapshot' + readonly cursor: number + readonly records: readonly FixtureHistoryRecord[] + readonly hasMore: boolean + readonly projections: { + readonly asOfSeq: number + readonly values: Readonly> + } + } + | FixtureHistoryEntry + +type FixtureControlFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly queues: Readonly> + readonly jobs: Readonly> + readonly approvals: readonly unknown[] + readonly questions: readonly unknown[] + readonly projections: Readonly> + }>> + } + } + | { + readonly type: 'projection' + readonly sessionId: SessionId + readonly key: string + readonly value: unknown + readonly seq: number + } + +interface FixtureSessionRequests { + list: { readonly cursor?: string } + search: { readonly query: string } + create: { + readonly workspaceId?: WorkspaceId + readonly cwd?: string + readonly sessionId?: SessionId + readonly agentPreset?: string + } + history: { + readonly sessionId: SessionId + readonly beforeSeq?: number + readonly maxMessages?: number + } + selectModel: { + readonly sessionId: SessionId + readonly provider: string + readonly model: string + readonly reasoningEffort?: string + } + prompt: { + readonly sessionId: SessionId + readonly mode: 'queue' | 'steer' + readonly content: readonly ({ readonly type: 'text'; readonly text: string } | { + readonly type: 'image' + readonly mediaType: 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif' + readonly data: string + readonly name?: string + })[] + } + cancel: { readonly sessionId: SessionId } + rename: { readonly sessionId: SessionId; readonly title: string } +} + +interface FixtureSessionValues { + list: { readonly items: FixtureSessionSummary[] } + search: { readonly items: readonly { readonly sessionId: SessionId; readonly snippet: string }[]; readonly hasMore: boolean } + create: { readonly sessionId: SessionId } + history: FixturePage + selectModel: { readonly selected: ModelSelection } + prompt: { readonly accepted: true } + cancel: Record + rename: { readonly title: string; readonly seq: number } +} + +type FixtureSessionApi = { + [K in keyof FixtureSessionRequests]: ( + request: RpcRequest, + signal?: AbortSignal, + ) => Promise> +} + +type FixtureSessionClient = { + [K in keyof FixtureSessionRequests]: ( + request: FixtureSessionRequests[K], + signal?: AbortSignal, + ) => Promise> +} + +interface FixtureSessionRemote { + follow(sessionId: SessionId, signal: AbortSignal): AsyncIterable + control(signal: AbortSignal): AsyncIterable +} + +interface FixtureWorkspaceView { + readonly workspaceId: WorkspaceId + readonly path: string + readonly title: string + readonly sessionIds: readonly SessionId[] + readonly createdAt: string + readonly updatedAt: string +} + +interface FixtureWorkspaceRequests { + create: { readonly path: string } + rename: { readonly workspaceId: WorkspaceId; readonly title: string } + delete: { readonly workspaceId: WorkspaceId } + insertBefore: { readonly workspaceId: WorkspaceId; readonly beforeWorkspaceId?: WorkspaceId } + insertSessionBefore: { + readonly workspaceId: WorkspaceId + readonly sessionId: SessionId + readonly beforeSessionId?: SessionId + } + archiveSession: { readonly sessionId: SessionId } +} + +interface FixtureWorkspaceValues { + create: { readonly workspace: FixtureWorkspaceView; readonly created: boolean } + rename: { readonly workspace: FixtureWorkspaceView } + delete: { readonly deleted: true } + insertBefore: { readonly workspaceIds: readonly WorkspaceId[] } + insertSessionBefore: { readonly workspace: FixtureWorkspaceView } + archiveSession: { readonly archivedSessionIds: readonly SessionId[] } +} + +type FixtureWorkspaceApi = { + [K in keyof FixtureWorkspaceRequests]: ( + request: RpcRequest, + signal?: AbortSignal, + ) => Promise> +} + +type FixtureWorkspaceClient = { + [K in keyof FixtureWorkspaceRequests]: ( + request: FixtureWorkspaceRequests[K], + signal?: AbortSignal, + ) => Promise> +} + +type FixtureWorkspaceFrame = + | { + readonly type: 'baseline' + readonly value: { + readonly items: readonly FixtureWorkspaceView[] + readonly archivedSessionIds: readonly SessionId[] + } + } + | { readonly type: 'upsert'; readonly workspace: FixtureWorkspaceView } + | { readonly type: 'remove'; readonly workspaceId: WorkspaceId } + | { readonly type: 'order'; readonly workspaceIds: readonly WorkspaceId[] } + | { readonly type: 'archived'; readonly archivedSessionIds: readonly SessionId[] } + +interface FixtureWorkspaceRemote { + follow(signal: AbortSignal): AsyncIterable +} + +interface FixtureRemoteEventNotificationFrame { + readonly type: 'emit' + readonly event: string + readonly args: readonly unknown[] +} + +interface FixtureRemoteEventRequestFrame { + readonly type: 'waterfall' + readonly event: string + readonly eventId: string + readonly agentId: SessionId + readonly request: Readonly> +} + +interface FixtureRemoteEventCancellationFrame { + readonly type: 'cancel' + readonly eventId: string +} + +type FixtureRemoteEventFrame = + | FixtureRemoteEventNotificationFrame + | FixtureRemoteEventRequestFrame + | FixtureRemoteEventCancellationFrame + +interface FixtureRemoteEventResult { + readonly clientId: string + readonly eventId: string + readonly outcome: + | { readonly kind: 'next' } + | { readonly kind: 'result'; readonly value?: unknown } + | { + readonly kind: 'rejected' + readonly error: { + readonly name: string + readonly message: string + readonly code?: string + readonly details?: unknown + } + } +} + +interface FixtureRemoteEventStream extends AsyncIterable { + readonly clientId: Promise +} + +type FixtureTestApi = ReturnType['api'] & { + readonly sessions: FixtureSessionApi + readonly sessionRemote: FixtureSessionRemote + readonly workspace: FixtureWorkspaceApi + readonly workspaceRemote: FixtureWorkspaceRemote + readonly remoteEvents: (signal: AbortSignal) => FixtureRemoteEventStream + readonly answerRemoteEvent: (result: FixtureRemoteEventResult) => Promise +} + +/** Keep existing fixture assertions compact while driving only the new Session Remote endpoints. */ +function createFixtureApi(options: FixtureOptions = {}): FixtureTestApi { + const { api, rpc } = createFixtureFaces(options) + return Object.assign(api, { + sessions: createSessionApi(rpc), + sessionRemote: createSessionRemote(rpc), + workspace: createWorkspaceApi(rpc), + workspaceRemote: createWorkspaceRemote(rpc), + remoteEvents: (signal: AbortSignal) => openFixtureRemoteEvents(rpc, signal), + answerRemoteEvent: (result: FixtureRemoteEventResult) => + rpc.call('/api', '$events/result', { args: result }), + }) +} + +function openFixtureRemoteEvents( + rpc: ClientConnectionRpc, + signal: AbortSignal, +): FixtureRemoteEventStream { + const ready = Promise.withResolvers() + const source = (async function* (): AsyncGenerator { + const stream = rpc.open?.('/api', '$events', { args: {} }, signal) + if (stream === undefined) throw new Error('fixture forwarded-event stream is unavailable') + let opened = false + for await (const value of stream) { + if (!opened) { + expect(value).toMatchObject({ type: 'ready' }) + const clientId: unknown = Reflect.get(value as object, 'clientId') + if (typeof clientId !== 'string') throw new Error('fixture forwarded-event stream omitted its Client id') + ready.resolve(clientId) + opened = true + continue + } + yield value as FixtureRemoteEventFrame + } + })() + return Object.assign(source, { clientId: ready.promise }) +} + +function createSessionApi(rpc: ClientConnectionRpc): FixtureSessionApi { + const call = async ( + endpoint: K, + request: RpcRequest, + signal?: AbortSignal, + ): Promise> => { + const page = endpoint === 'history' + ? request.payload as FixtureSessionRequests['history'] + : undefined + const args = endpoint === 'list' + ? { _request: request.payload } + : endpoint === 'history' + ? { + request: { + address: { kind: 'session', sessionId: page?.sessionId }, + ...page?.beforeSeq === undefined ? {} : { beforeSeq: page.beforeSeq }, + ...page?.maxMessages === undefined ? {} : { maxMessages: page.maxMessages }, + }, + } + : { request: request.payload } + const remoteEndpoint = endpoint === 'history' ? 'page' : endpoint + const result = await rpc.call('/api', `session/${remoteEndpoint}`, { args }, signal) + return { + rpcId: request.rpcId, + result: result as unknown as RpcResult, + } + } + return { + list: (request, signal) => call('list', request, signal), + search: (request, signal) => call('search', request, signal), + create: (request, signal) => call('create', request, signal), + history: (request, signal) => call('history', request, signal), + selectModel: (request, signal) => call('selectModel', request, signal), + prompt: (request, signal) => call('prompt', request, signal), + cancel: (request, signal) => call('cancel', request, signal), + rename: (request, signal) => call('rename', request, signal), + } +} + +function createSessionClient(rpc: ClientConnectionRpc): FixtureSessionClient { + const api = createSessionApi(rpc) + return { + list: (request, signal) => api.list(req(request), signal), + search: (request, signal) => api.search(req(request), signal), + create: (request, signal) => api.create(req(request), signal), + history: (request, signal) => api.history(req(request), signal), + selectModel: (request, signal) => api.selectModel(req(request), signal), + prompt: (request, signal) => api.prompt(req(request), signal), + cancel: (request, signal) => api.cancel(req(request), signal), + rename: (request, signal) => api.rename(req(request), signal), + } +} + +function createSessionRemote(rpc: ClientConnectionRpc): FixtureSessionRemote { + const open = (endpoint: string, args: object, signal: AbortSignal): AsyncIterable => { + const stream = rpc.open?.('/api', endpoint, { args }, signal) + if (stream === undefined) throw new Error(`fixture ${endpoint} stream is unavailable`) + return stream as AsyncIterable + } + return { + follow: (sessionId, signal) => open('session/follow', { + request: { address: { kind: 'session', sessionId } }, + }, signal), + control: signal => open('session/control', {}, signal), + } +} + +function createWorkspaceApi(rpc: ClientConnectionRpc): FixtureWorkspaceApi { + const call = async ( + endpoint: K, + request: RpcRequest, + signal?: AbortSignal, + ): Promise> => { + const result = await rpc.call('/api', `workspace/${endpoint}`, { + args: { request: request.payload }, + }, signal) + return { + rpcId: request.rpcId, + result: result as unknown as RpcResult, + } + } + return { + create: (request, signal) => call('create', request, signal), + rename: (request, signal) => call('rename', request, signal), + delete: (request, signal) => call('delete', request, signal), + insertBefore: (request, signal) => call('insertBefore', request, signal), + insertSessionBefore: (request, signal) => call('insertSessionBefore', request, signal), + archiveSession: (request, signal) => call('archiveSession', request, signal), + } +} + +function createWorkspaceClient(rpc: ClientConnectionRpc): FixtureWorkspaceClient { + const api = createWorkspaceApi(rpc) + return { + create: (request, signal) => api.create(req(request), signal), + rename: (request, signal) => api.rename(req(request), signal), + delete: (request, signal) => api.delete(req(request), signal), + insertBefore: (request, signal) => api.insertBefore(req(request), signal), + insertSessionBefore: (request, signal) => api.insertSessionBefore(req(request), signal), + archiveSession: (request, signal) => api.archiveSession(req(request), signal), + } +} + +function createWorkspaceRemote(rpc: ClientConnectionRpc): FixtureWorkspaceRemote { + return { + follow(signal) { + const stream = rpc.open?.('/api', 'workspace/follow', { args: {} }, signal) + if (stream === undefined) throw new Error('fixture workspace/follow stream is unavailable') + return stream as AsyncIterable + }, + } +} + interface TimingHooks { setHistoryDelay(ms: number): void failNextHistory(): void @@ -38,11 +469,11 @@ interface TimingHooks { } const timing = (): TimingHooks => (globalThis as Record).__fxTiming as TimingHooks -/** Collect stream frames until the predicate or a soft cap; abort ends the stream. */ -async function collect(stream: AsyncIterable>, abort: AbortController, done: (frames: F[]) => boolean): Promise { +/** Collect value-stream frames until the predicate or a soft cap; abort ends the stream. */ +async function collectValues(stream: AsyncIterable, abort: AbortController, done: (frames: F[]) => boolean): Promise { const frames: F[] = [] - for await (const envelope of stream) { - frames.push(envelope.payload) + for await (const frame of stream) { + frames.push(frame) if (done(frames) || frames.length > 500) { abort.abort() break @@ -51,6 +482,70 @@ async function collect(stream: AsyncIterable>, abort: AbortCont return frames } +async function readControlBaseline(remote: FixtureSessionRemote): Promise> { + const abort = new AbortController() + for await (const frame of remote.control(abort.signal)) { + if (frame.type !== 'baseline') continue + abort.abort() + return frame + } + throw new Error('fixture control baseline missing') +} + +function isRemoteEventRequest(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventRequestFrame { + return frame.type === 'waterfall' +} + +function isRemoteEventCancellation(frame: FixtureRemoteEventFrame): frame is FixtureRemoteEventCancellationFrame { + return frame.type === 'cancel' +} + +async function readResidentRemoteEvents( + api: FixtureTestApi, + count: number, +): Promise { + const abort = new AbortController() + const frames = await collectValues( + api.remoteEvents(abort.signal), + abort, + seen => seen.filter(isRemoteEventRequest).length >= count, + ) + return frames.filter(isRemoteEventRequest) +} + +async function nextRemoteEvent( + iterator: AsyncIterator, + predicate: (frame: FixtureRemoteEventFrame) => boolean, +): Promise { + for (;;) { + const item = await iterator.next() + if (item.done) throw new Error('fixture Remote Event stream ended before the expected frame') + if (predicate(item.value)) return item.value + } +} + +async function readOpeningCursor(remote: FixtureSessionRemote, sessionId: SessionId): Promise { + const abort = new AbortController() + for await (const frame of remote.follow(sessionId, abort.signal)) { + if (frame.type !== 'snapshot') continue + abort.abort() + return frame.cursor + } + throw new Error('fixture follow opening cursor missing') +} + +async function readWorkspaceBaseline( + remote: FixtureWorkspaceRemote, +): Promise['value']> { + const abort = new AbortController() + for await (const frame of remote.follow(abort.signal)) { + if (frame.type !== 'baseline') continue + abort.abort() + return frame.value + } + throw new Error('fixture Workspace baseline missing') +} + describe('createFixtureApi', () => { it('serves the session list sorted by updatedAt desc and echoes rpcIds on every unary', async () => { const api = createFixtureApi() @@ -121,69 +616,69 @@ describe('createFixtureApi', () => { if (!tail.result.ok) throw new Error('history failed') const tailPage = tail.result.value expect(tailPage.hasMore).toBe(true) - expect(tailPage.events[0]?.event.type).toBe('turn/start') // cut lands on a turn boundary - const boundary = tailPage.events[0]?.event.seq ?? 0 + const tailEvents = historyEvents(tailPage.records) + expect(tailEvents[0]?.type).toBe('turn/start') // cut lands on a turn boundary + const boundary = tailEvents[0]?.seq ?? 0 expect(boundary).toBeGreaterThan(0) const older = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: boundary, maxMessages: 10 })) if (!older.result.ok) throw new Error('older failed') - const olderTail = older.result.value.events.at(-1)?.event + const olderTail = historyEvents(older.result.value.records).at(-1) expect((olderTail?.seq ?? -1) + 1).toBe(boundary) // pages stitch with no hole/overlap // Out-of-range beforeSeq clamps instead of exploding. const clamped = await api.sessions.history(req({ sessionId: sid('fx-alpha'), beforeSeq: -5, maxMessages: 10 })) if (!clamped.result.ok) throw new Error('clamped failed') - expect(clamped.result.value.events).toEqual([]) - // Unknown session: empty page, not an error (history of a bare id). The - // tail block still rides it — empty-log cut at -1, the host convention. + expect(clamped.result.value.records).toEqual([]) + // 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') - // Fixture composes the todos + plan units (host parallel when tool-todo - // and plan-mode are mounted): the empty-log values. - expect(empty.result.value).toEqual({ - events: [], hasMore: false, projections: { asOfSeq: -1, values: { - todos: null, - // Permission unit composed: the composition-default select. - permissions: { - options: [ - { value: 'workspace-write', name: 'workspace-write', description: 'Write inside the workspace and permitted temporary directories; wider retries require approval.' }, - { value: 'danger-full-access', name: 'danger-full-access', description: 'Full file access without approval prompts.' }, + expect(empty.result.value).toEqual({ records: [], hasMore: false }) + }) + + it('serves raw history entries with replayable tool-result metadata', async () => { + const api = createFixtureApi() + const response = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 200 })) + if (!response.result.ok) throw new Error('history failed') + + const records = response.result.value.records + const results = historyEvents(records) + .filter(event => event.type === 'tool/result') + + expect(results.find(event => event.data.turn === 64)).toMatchObject({ + data: { + meta: { + diffs: [ + { path: 'src/config.ts', oldText: 'const timeout = 30', newText: 'const timeout = 60' }, + { path: 'src/config.ts', oldText: 'retries: 1', newText: 'retries: 3' }, ], - currentValue: 'workspace-write', }, - plan: { active: false, pending: false }, - goal: null, - tokenUsage: { - uncachedInputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - }, - // No request ran, so neither pressure nor capacity is known yet. - contextPressure: {}, - contextBreakdown: { - systemTokens: 0, - toolsTokens: 0, - messageTokens: 0, - }, - // Session-stats unit composed: no figure accrues on the empty log. - sessionStats: { - turns: 0, steps: 0, llmMs: 0, toolMs: 0, ttftMs: 0, ttftSteps: 0, decodeMs: 0, decodeTokens: 0, - }, - imageLimits: { - maxImageBytes: 5 * 1024 * 1024, - maxImagesPerMessage: 20, - maxMessageImageBytes: 100 * 1024 * 1024, - maxImagePixels: 40_000_000, - maxImageDimension: 2000, - mediaTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], - }, - } }, + }, }) + expect(results.find(event => event.data.turn === 67)).toMatchObject({ + data: { meta: { shape: 'matches', truncated: true, total: 42 } }, + }) + expect(results.find(event => event.data.turn === 69)).toMatchObject({ + data: { meta: { path: 'packages/client/ui-primitives/src/ReadBlock.tsx', offset: 41, totalLines: 180 } }, + }) + const webSearch = results.find(event => event.data.turn === 70) + expect(webSearch).toHaveProperty('data.meta.truncated', true) + expect(webSearch).toHaveProperty('data.meta.sources', expect.arrayContaining([ + expect.objectContaining({ url: 'https://github.com/deepseek-ai/deepseek-harness' }), + ])) + expect(results.find(event => event.data.turn === 71)).toMatchObject({ + data: { meta: { url: 'https://www.deepseek.com/blog/harness-architecture', statusCode: 200 } }, + }) + const terminal = results.find(event => event.data.turn === 66) + expect(terminal).toHaveProperty('data.message.content.0.content.0.type', 'text') + expect(terminal).toHaveProperty( + 'data.message.content.0.content.0.text', + expect.stringContaining('\n[exit code: 1]'), + ) }) it('serves grouped models and keeps a selection for later history and fixture requests', async () => { const api = createFixtureApi() const sessionId = sid('fx-alpha') - const catalog = await api.sessions.models(req({ sessionId })) + const catalog = await api.llm.models(req({})) 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)) @@ -208,7 +703,7 @@ describe('createFixtureApi', () => { 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') + expect(JSON.stringify(after.result.value.records)).toContain('openai/gpt-5') }) it('serves configured DeepSeek readiness and keeps credential values write-only', async () => { @@ -245,7 +740,7 @@ describe('createFixtureApi', () => { 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 events = historyEvents(tail.result.value.records) const todoAt = events.findIndex(e => e.type === 'todo/write') expect(todoAt).toBeGreaterThan(0) // Production ordering (the tool appends mid-execution): call → snapshot → result. @@ -260,14 +755,16 @@ describe('createFixtureApi', () => { expect(snapshot.data.todos.filter(t => t.status === 'in_progress')).toHaveLength(2) }) - it('create adds a session and pushes host/session-added to open host streams', async () => { + it('create adds a session and announces it through the Host Remote event stream', async () => { const api = createFixtureApi() const abort = new AbortController() - const seen: HostFrame[] = [] + const seen: FixtureRemoteEventNotificationFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 1) abort.abort() + for await (const frame of api.remoteEvents(abort.signal)) { + if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue + seen.push(frame) + abort.abort() + break } })() await new Promise(resolve => setTimeout(resolve, 10)) // let the stream register @@ -278,9 +775,9 @@ describe('createFixtureApi', () => { const createdId = created.result.value.sessionId expect(seen).toHaveLength(1) const added = seen[0] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: createdId, blank: true, cwd: '/tmp/fixture', + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ sessionId: createdId, blank: true, cwd: '/tmp/fixture' }], }) const list = await api.sessions.list(req({})) if (!list.result.ok) throw new Error('list failed') @@ -292,16 +789,16 @@ describe('createFixtureApi', () => { const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId - const abort = new AbortController() - const frames: MuxFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) { - frames.push(envelope.payload) - const last = envelope.payload - if (last.type === 'session/event' && last.event.type === 'turn/end') { - abort.abort() - } - } + const followAbort = new AbortController() + const controlAbort = new AbortController() + const controlFrames: FixtureControlFrame[] = [] + const followPromise = collectValues( + api.sessionRemote.follow(id, followAbort.signal), + followAbort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), + ) + const controlPromise = (async () => { + for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlFrames.push(frame) })() await new Promise(resolve => setTimeout(resolve, 10)) // Unknown session → session-not-found with the id echoed in details. @@ -312,8 +809,8 @@ describe('createFixtureApi', () => { expect(accepted.result).toMatchObject({ ok: true, value: { accepted: true } }) await new Promise(resolve => setTimeout(resolve, 120)) // a couple of typewriter ticks await api.sessions.cancel(req({ sessionId: id })) - await consuming - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const frames = await followPromise + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(types).toContain('turn/start') expect(types).toContain('user/message') expect(types).toContain('assistant/chunk') @@ -322,20 +819,25 @@ describe('createFixtureApi', () => { // Capacity is durable log state, not a transient frame: the prompt path // records request/context and the projection carries it to the client. expect(types).toContain('request/context') - expect(frames.some(frame => - frame.type === 'session/projection' + await vi.waitFor(() => { + expect(controlFrames.some(frame => + frame.type === 'projection' + && frame.key === 'contextBreakdown' + && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) + }) + expect(controlFrames.some(frame => + frame.type === 'projection' && frame.key === 'tokenUsage' && (frame.value as { outputTokens?: number }).outputTokens === 8)).toBe(true) - expect(frames.some(frame => - frame.type === 'session/projection' + expect(controlFrames.some(frame => + frame.type === 'projection' && frame.key === 'contextPressure' && (frame.value as { contextWindow?: number }).contextWindow === 128_000)).toBe(true) - expect(frames.some(frame => - frame.type === 'session/projection' - && frame.key === 'contextBreakdown' - && (frame.value as { messageTokens?: number }).messageTokens! > 0)).toBe(true) - const finalize = frames.find((f): f is Extract => f.type === 'session/event' && f.event.type === 'assistant/message') + const finalize = frames.find(frame => frame.type === 'event' && frame.event.type === 'assistant/message') + if (finalize?.type !== 'event') throw new Error('assistant final event missing') expect(JSON.stringify(finalize?.event.data)).toContain('(已中断)') + controlAbort.abort() + await controlPromise // Idle cancel: no replay in flight, must not explode; running flips false. const idleCancel = await api.sessions.cancel(req({ sessionId: id })) expect(idleCancel.result).toMatchObject({ ok: true }) @@ -347,99 +849,93 @@ describe('createFixtureApi', () => { if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId const abort = new AbortController() - const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, - frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end')) + const framesPromise = collectValues(api.sessionRemote.follow(id, abort.signal), abort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end')) await new Promise(resolve => setTimeout(resolve, 10)) await api.sessions.prompt(req({ sessionId: id, mode: 'queue' as const, content: [{ type: 'text' as const, text: '短' }] })) await api.sessions.prompt(req({ sessionId: id, mode: 'steer' as const, content: [{ type: 'text' as const, text: '插话' }] })) const frames = await framesPromise - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(JSON.stringify(frames)).toContain('插话') expect(types.at(-1)).toBe('turn/end') // steer did not restart the turn }) - it('mux open replays subscribed sessions and resident interactions with stable rpcIds', async () => { + it('control replays projections while resident Remote Events retain ids across reconnects', async () => { const api = createFixtureApi() - const openOnce = async (): Promise[]> => { - const abort = new AbortController() - const envelopes: RpcRequest[] = [] - for await (const envelope of api.events.mux(req({}), abort.signal)) { - envelopes.push(envelope) - if (envelopes.length >= 13) abort.abort() - } - return envelopes - } - const first = await openOnce() - const second = await openOnce() - expect(first[0]?.payload).toMatchObject({ type: 'session/subscribed', sessionId: 'fx-alpha' }) - expect((first[0]?.payload as { lastSeq: number }).lastSeq).toBeGreaterThan(0) - // Projection baseline frames follow subscribed (domain units + token usage). - expect(first[1]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'title', value: 'Fixture 历史会话' }) - expect(first[2]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'todos' }) - expect(first[3]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'permissions' }) - expect(first[4]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'plan', value: { active: false, pending: false } }) - expect(first[5]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'goal', value: null }) - expect(first[6]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'tokenUsage' }) - expect(first[7]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'contextPressure' }) - expect(first[8]?.payload).toMatchObject({ - type: 'session/projection', sessionId: 'fx-alpha', key: 'contextBreakdown', - value: { systemTokens: 0, toolsTokens: 0 }, + const first = await readControlBaseline(api.sessionRemote) + const second = await readControlBaseline(api.sessionRemote) + expect(first.value.approvals).toEqual([]) + expect(first.value.questions).toEqual([]) + const alpha = first.value.projections['fx-alpha'] + expect(alpha?.asOfSeq).toBeGreaterThan(0) + expect(alpha?.values).toMatchObject({ + title: 'Fixture 历史会话', + plan: { active: false, pending: false }, + goal: null, + imageLimits: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, }) - expect((first[8]?.payload as { value: { messageTokens: number } }).value.messageTokens).toBeGreaterThan(0) - expect(first[9]?.payload).toMatchObject({ type: 'session/projection', sessionId: 'fx-alpha', key: 'sessionStats' }) - expect((first[9]?.payload as { value: { turns: number; steps: number } }).value.steps).toBeGreaterThan(0) - expect(first[10]?.payload).toMatchObject({ - type: 'session/projection', sessionId: 'fx-alpha', key: 'imageLimits', - value: { maxImagesPerMessage: 20, maxImageBytes: 5 * 1024 * 1024 }, + expect((alpha?.values['contextBreakdown'] as { messageTokens: number }).messageTokens).toBeGreaterThan(0) + expect((alpha?.values['sessionStats'] as { steps: number }).steps).toBeGreaterThan(0) + expect(second.value.projections['fx-alpha']).toEqual(alpha) + + const firstEvents = await readResidentRemoteEvents(api, 2) + const secondEvents = await readResidentRemoteEvents(api, 2) + const firstApproval = firstEvents.find(frame => frame.event === 'approval/request') + const firstQuestion = firstEvents.find(frame => frame.event === 'user-questions/request') + const secondApproval = secondEvents.find(frame => frame.event === 'approval/request') + const secondQuestion = secondEvents.find(frame => frame.event === 'user-questions/request') + expect(firstApproval).toMatchObject({ + type: 'waterfall', + request: { toolName: 'dangerous_tool' }, + agentId: 'fx-alpha', }) - expect(first[11]?.payload).toMatchObject({ type: 'approval/requested', toolName: 'dangerous_tool' }) - expect(second[11]?.rpcId).toBe(first[11]?.rpcId) // stable rpcId across replays (host replay semantics) - expect(first[12]?.payload).toMatchObject({ type: 'question/requested', sessionId: 'fx-alpha' }) - expect(second[12]?.rpcId).toBe(first[12]?.rpcId) + expect(firstQuestion).toMatchObject({ + type: 'waterfall', + agentId: 'fx-alpha', + }) + expect(Array.isArray(firstQuestion?.request.questions)).toBe(true) + expect(secondApproval?.eventId).toBe(firstApproval?.eventId) + expect(secondQuestion?.eventId).toBe(firstQuestion?.eventId) + expect(await readOpeningCursor(api.sessionRemote, sid('fx-alpha'))).toBeGreaterThan(0) }) it('steer with no replay in flight falls through to a fresh queued turn; non-text blocks stringify empty', async () => { const api = createFixtureApi() - const abort = new AbortController() - const framesPromise = collect(api.events.mux(req({}), abort.signal), abort, - frames => frames.some(f => f.type === 'session/event' && f.event.type === 'turn/end')) - await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.sessions.create(req({})) if (!created.result.ok) throw new Error('create failed') + const abort = new AbortController() + const framesPromise = collectValues( + api.sessionRemote.follow(created.result.value.sessionId, abort.signal), + abort, + frames => frames.some(frame => frame.type === 'event' && frame.event.type === 'turn/end'), + ) + await new Promise(resolve => setTimeout(resolve, 10)) // steer while idle + a non-text content block (covers the '' arm of the text join). await api.sessions.prompt(req({ sessionId: created.result.value.sessionId, mode: 'steer' as const, content: [{ type: 'text' as const, text: '短' }, { type: 'image', data: 'x' } as never], })) const frames = await framesPromise - const types = frames.filter((f): f is Extract => f.type === 'session/event').map(f => f.event.type) + const types = frames.flatMap(frame => frame.type === 'event' ? [frame.event.type] : []) expect(types[0]).toBe('turn/start') // idle steer degraded to a queued turn, not an in-turn insert }) - it('gamma interval flip emits host/session-status and a running log-less session subscribes at lastSeq -1', async () => { + it('gamma interval flip emits a Remote status event and its empty follow source opens at -1', async () => { vi.useFakeTimers() try { const api = createFixtureApi() const abort = new AbortController() - const hostSeen: HostFrame[] = [] + const hostSeen: FixtureRemoteEventFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) hostSeen.push(envelope.payload) + for await (const frame of api.remoteEvents(abort.signal)) hostSeen.push(frame) })() await vi.advanceTimersByTimeAsync(5001) // interval fires: fx-gamma flips running=true (no log exists) - expect(hostSeen).toContainEqual({ type: 'host/session-status', sessionId: sid('fx-gamma'), running: true }) - // A mux stream opened now sees gamma in the baseline with lastSeq = -1 (empty log arm). - const mabort = new AbortController() - const baseline: MuxFrame[] = [] - const muxConsuming = (async () => { - for await (const envelope of api.events.mux(req({}), mabort.signal)) { - baseline.push(envelope.payload) - if (baseline.length >= 3) mabort.abort() - } - })() - await vi.advanceTimersByTimeAsync(10) - mabort.abort() - await muxConsuming - expect(baseline).toContainEqual({ type: 'session/subscribed', sessionId: sid('fx-gamma'), lastSeq: -1 }) + expect(hostSeen).toContainEqual({ + type: 'emit', + event: 'api-session/status', + args: [sid('fx-gamma'), true], + }) + expect(await readOpeningCursor(api.sessionRemote, sid('fx-gamma'))).toBe(-1) abort.abort() await vi.advanceTimersByTimeAsync(10) await consuming @@ -448,76 +944,97 @@ describe('createFixtureApi', () => { } }) - it('respond resolves the resident question once and rejects duplicate or unrelated ids', async () => { + it('answers a resident question through its Remote Event id and stops replaying it', async () => { const api = createFixtureApi() - expect(await api.respond({ type: 'client-response', rpcId: RpcId('x'), result: { ok: true, value: {} } })).toEqual({ accepted: false, reason: 'not-pending' }) const abort = new AbortController() - let question: RpcRequest | undefined - for await (const envelope of api.events.mux(req({}), abort.signal)) { - if (envelope.payload.type !== 'question/requested') continue - question = envelope - abort.abort() - } - if (question === undefined) throw new Error('fixture question missing') - const response = { type: 'client-response' as const, rpcId: question.rpcId, result: { ok: true as const, value: {} } } - expect(await api.respond(response)).toEqual({ accepted: true }) - expect(await api.respond(response)).toEqual({ accepted: false, reason: 'not-pending' }) + const stream = api.remoteEvents(abort.signal) + const iterator = stream[Symbol.asyncIterator]() + const question = await nextRemoteEvent( + iterator, + frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', + ) + if (!isRemoteEventRequest(question)) throw new Error('fixture question Remote Event missing') + const clientId = await stream.clientId + await expect(api.answerRemoteEvent({ + clientId, + eventId: 'unrelated', + outcome: { kind: 'result', value: {} }, + })).resolves.toEqual({ ok: true, value: undefined }) + await expect(api.answerRemoteEvent({ + clientId, + eventId: question.eventId, + outcome: { kind: 'result', value: { answers: {} } }, + })).resolves.toEqual({ ok: true, value: undefined }) + const cancelled = await nextRemoteEvent( + iterator, + frame => isRemoteEventCancellation(frame) && frame.eventId === question.eventId, + ) + expect(cancelled).toEqual({ type: 'cancel', eventId: question.eventId }) + abort.abort() + await iterator.return?.() - const replayAbort = new AbortController() - const replayed = await collect(api.events.mux(req({}), replayAbort.signal), replayAbort, frames => frames.length === 2) - expect(replayed.every(frame => frame.type !== 'question/requested')).toBe(true) + await expect(api.answerRemoteEvent({ + clientId, + eventId: question.eventId, + outcome: { kind: 'result', value: { answers: {} } }, + })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + const remaining = await readResidentRemoteEvents(api, 1) + expect(remaining.map(frame => frame.event)).toEqual(['approval/request']) const cancelledApi = createFixtureApi() const cancelAbort = new AbortController() - let cancelQuestion: RpcRequest | undefined - for await (const envelope of cancelledApi.events.mux(req({}), cancelAbort.signal)) { - if (envelope.payload.type !== 'question/requested') continue - cancelQuestion = envelope - cancelAbort.abort() - } - if (cancelQuestion === undefined) throw new Error('fixture cancellation question missing') - expect(await cancelledApi.respond({ - type: 'client-response', rpcId: cancelQuestion.rpcId, - result: { ok: false, error: { code: 'cancelled', message: 'skip', details: {} } }, - })).toEqual({ accepted: true }) + const cancelStream = cancelledApi.remoteEvents(cancelAbort.signal) + const cancelIterator = cancelStream[Symbol.asyncIterator]() + const cancelQuestion = await nextRemoteEvent( + cancelIterator, + frame => isRemoteEventRequest(frame) && frame.event === 'user-questions/request', + ) + if (!isRemoteEventRequest(cancelQuestion)) throw new Error('fixture cancellation question missing') + await expect(cancelledApi.answerRemoteEvent({ + clientId: await cancelStream.clientId, + eventId: cancelQuestion.eventId, + outcome: { + kind: 'rejected', + error: { name: 'UserQuestionError', message: 'skip', code: 'ASK_CANCELLED' }, + }, + })).resolves.toEqual({ ok: true, value: undefined }) + cancelAbort.abort() + await cancelIterator.return?.() + const afterCancellation = await readResidentRemoteEvents(cancelledApi, 1) + expect(afterCancellation.map(frame => frame.event)).toEqual(['approval/request']) }) - it('respond answers the resident approval once: routing, validation, resolved broadcast, then not-pending', async () => { + it('answers a resident approval and broadcasts cancellation to its active delivery', async () => { const api = createFixtureApi() - // Discover the resident approval's stable rpcId from the mux baseline. const abort = new AbortController() - const seen: { rpcId: string; frame: MuxFrame }[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push({ rpcId: envelope.rpcId, frame: envelope.payload }) - })() - await vi.waitFor(() => { - expect(seen.some(s => s.frame.type === 'approval/requested')).toBe(true) - }) - const requested = seen.find(s => s.frame.type === 'approval/requested') - if (requested === undefined || requested.frame.type !== 'approval/requested') throw new Error('unreachable') - const approvalId = requested.frame.approvalId + const stream = api.remoteEvents(abort.signal) + const iterator = stream[Symbol.asyncIterator]() + const approval = await nextRemoteEvent( + iterator, + frame => isRemoteEventRequest(frame) && frame.event === 'approval/request', + ) + if (!isRemoteEventRequest(approval)) throw new Error('fixture approval Remote Event missing') - // Routed but malformed answers. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: false, error: { code: 'internal', message: 'x', details: {} } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId: 'wrong', outcome: 'rejected' } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { approvalId, outcome: 'maybe' } } })) - .toEqual({ accepted: false, reason: 'bad-response' }) - // The real answer settles the question and broadcasts resolved. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'allowed-once' } } })) - .toEqual({ accepted: true }) - await vi.waitFor(() => { - expect(seen.some(s => s.frame.type === 'approval/resolved' && s.frame.outcome === 'allowed-once')).toBe(true) - }) - // Settled: a duplicate answer is late, and a fresh mux open replays nothing. - expect(await api.respond({ type: 'client-response', rpcId: RpcId(requested.rpcId), result: { ok: true, value: { sessionId: sid('fx-alpha'), approvalId, outcome: 'rejected' } } })) - .toEqual({ accepted: false, reason: 'not-pending' }) + await expect(api.answerRemoteEvent({ + clientId: await stream.clientId, + eventId: approval.eventId, + outcome: { kind: 'result', value: 'allowed-once' }, + })).resolves.toEqual({ ok: true, value: undefined }) + const cancelled = await nextRemoteEvent( + iterator, + frame => isRemoteEventCancellation(frame) && frame.eventId === approval.eventId, + ) + expect(cancelled).toEqual({ type: 'cancel', eventId: approval.eventId }) abort.abort() - await consuming - const abort2 = new AbortController() - const replayed = await collect(api.events.mux(req({}), abort2.signal), abort2, frames => frames.length === 2) - expect(replayed.some(f => f.type === 'approval/requested')).toBe(false) + await iterator.return?.() + + await expect(api.answerRemoteEvent({ + clientId: await stream.clientId, + eventId: approval.eventId, + outcome: { kind: 'next' }, + })).resolves.toMatchObject({ ok: false, error: { code: 'invocation-unavailable' } }) + const remaining = await readResidentRemoteEvents(api, 1) + expect(remaining.map(frame => frame.event)).toEqual(['user-questions/request']) }) it('describe answers the fixture identity', async () => { @@ -546,11 +1063,10 @@ describe('createFixtureApi', () => { expect(root.result.value.entries).toContainEqual({ name: 'srv', path: '/srv', hidden: false }) }) - it('workspace.list serves the resident account and create reuses on path collision', async () => { + it('workspace/follow serves the resident baseline and create reuses on path collision', async () => { const api = createFixtureApi() - const listed = await api.workspace.list(req({})) - if (!listed.result.ok) throw new Error('list failed') - expect(listed.result.value.items).toEqual([ + const baseline = await readWorkspaceBaseline(api.workspaceRemote) + expect(baseline.items).toEqual([ expect.objectContaining({ workspaceId: 'fx-ws-fixture', path: '/tmp/fixture', title: 'fixture', sessionIds: ['fx-alpha', 'fx-beta', 'fx-gamma'], @@ -566,16 +1082,15 @@ describe('createFixtureApi', () => { expect(reused.result.value).toMatchObject({ created: false, workspace: { workspaceId: 'fx-ws-fixture' } }) }) - it('workspace.create on a fresh path mints a new entity and pushes host/workspace-changed', async () => { + it('workspace.create on a fresh path mints a new entity and pushes an upsert', 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() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.path === '/tmp/fixture-workspaces/nova'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const created = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!created.result.ok) throw new Error('create failed') @@ -583,8 +1098,8 @@ describe('createFixtureApi', () => { expect(created.result.value.workspace).toMatchObject({ path: '/tmp/fixture-workspaces/nova', title: 'nova', sessionIds: [], }) - await consuming - expect(seen).toEqual([{ type: 'host/workspace-changed', workspace: created.result.value.workspace }]) + const frames = await consuming + expect(frames.at(-1)).toEqual({ type: 'upsert', workspace: created.result.value.workspace }) // A basename-less path serves as its own title. const rootPath = await api.workspace.create(req({ path: '/' })) if (!rootPath.result.ok) throw new Error('rootPath failed') @@ -594,13 +1109,11 @@ describe('createFixtureApi', () => { it('workspace.rename covers not-found, conflict, no-op, and the changed 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) - if (seen.length >= 2) abort.abort() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.filter(frame => frame.type === 'upsert').length >= 2, + ) await new Promise(resolve => setTimeout(resolve, 10)) const wsid = 'fx-ws-fixture' as WorkspaceId const missing = await api.workspace.rename(req({ workspaceId: 'fx-ws-void' as WorkspaceId, title: 'x' })) @@ -617,22 +1130,28 @@ describe('createFixtureApi', () => { const renamed = await api.workspace.rename(req({ workspaceId: wsid, title: 'renamed' })) if (!renamed.result.ok) throw new Error('rename failed') expect(renamed.result.value.workspace.title).toBe('renamed') - await consuming + const frames = await consuming // Only the create and the effective rename emit frames; the no-op stays silent. - expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed']) + const upserts = frames.filter(frame => frame.type === 'upsert') + expect(upserts).toHaveLength(2) + expect(upserts[1]).toMatchObject({ workspace: { workspaceId: wsid, title: 'renamed' } }) }) it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => { const api = createFixtureApi() - const abort = new AbortController() - const framesPromise = (async () => { - const frames: MuxFrame[] = [] - for await (const envelope of api.events.mux(req({}), abort.signal)) { - frames.push(envelope.payload) - if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort() - } - return frames - })() + const followAbort = new AbortController() + const controlAbort = new AbortController() + const followPromise = collectValues( + api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal), + followAbort, + frames => frames.some(frame => frame.type === 'event' + && (frame.event as { type: string }).type === 'session/title'), + ) + const controlPromise = collectValues( + api.sessionRemote.control(controlAbort.signal), + controlAbort, + frames => frames.some(frame => frame.type === 'projection' && frame.key === 'title' && frame.value === '重命名'), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' })) @@ -650,15 +1169,21 @@ describe('createFixtureApi', () => { // so the event is located by seq and its payload checked structurally). const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 })) if (!history.result.ok) throw new Error('history failed') - const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq) - expect(appended?.event).toMatchObject({ + const appended = historyEvents(history.result.value.records).find(event => event.seq === acceptedSeq) + expect(appended).toMatchObject({ type: 'session/title', data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } }, }) - // Beyond the subscribe-time baseline replay, the append emitted exactly - // one title projection frame carrying the new value at the response seq. - const frames = await framesPromise - const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名') + const followed = await followPromise + expect(followed.some(frame => frame.type === 'event' + && frame.event.seq === acceptedSeq + && (frame.event as { readonly type: string }).type === 'session/title')).toBe(true) + const frames = await controlPromise + const titleFrames = frames.filter(frame => + frame.type === 'projection' + && frame.key === 'title' + && frame.sessionId === sid('fx-alpha') + && frame.value === '重命名') expect(titleFrames).toHaveLength(1) expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq }) }) @@ -689,23 +1214,20 @@ describe('createFixtureApi', () => { 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() - } - })() + const consuming = collectValues( + api.workspaceRemote.follow(abort.signal), + abort, + frames => frames.some(frame => frame.type === 'remove'), + ) 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 frames = await consuming + expect(frames.at(-1)).toEqual({ type: 'remove', workspaceId: 'fx-ws-fixture' }) + const baseline = await readWorkspaceBaseline(api.workspaceRemote) + expect(baseline.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') @@ -713,14 +1235,23 @@ describe('createFixtureApi', () => { it('session.create({workspaceId}) lands on the account and unknown ids error', async () => { const api = createFixtureApi() - const abort = new AbortController() - const seen: HostFrame[] = [] + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const seen: FixtureRemoteEventNotificationFrame[] = [] const consuming = (async () => { - for await (const envelope of api.events.host(req({}), abort.signal)) { - seen.push(envelope.payload) - if (seen.length >= 2) abort.abort() + for await (const frame of api.remoteEvents(hostAbort.signal)) { + if (frame.type !== 'emit' || frame.event !== 'api-session/added') continue + seen.push(frame) + hostAbort.abort() + break } })() + const workspaceFrames = collectValues( + api.workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.length === 4), + ) await new Promise(resolve => setTimeout(resolve, 10)) const missing = await api.sessions.create(req({ workspaceId: 'fx-ws-void' as WorkspaceId })) expect(missing.result).toMatchObject({ ok: false, error: { code: 'workspace-not-found', details: { workspaceId: 'fx-ws-void' } } }) @@ -728,30 +1259,44 @@ describe('createFixtureApi', () => { if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId await consuming - // The session lands with the workspace's path as cwd, and the account - // write pushes the fresh workspace snapshot after session-added. const added = seen[0] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: id, blank: true, cwd: '/tmp/fixture', + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ sessionId: id, blank: true, cwd: '/tmp/fixture' }], }) - expect(seen[1]).toMatchObject({ - type: 'host/workspace-changed', - workspace: { workspaceId: 'fx-ws-fixture', sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'] }, + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', + workspace: { + workspaceId: 'fx-ws-fixture', + sessionIds: [id, 'fx-alpha', 'fx-beta', 'fx-gamma'], + }, }) }) - it('supports an empty baseline, preallocated ids, workspace-first frames, and idempotent retry', async () => { + it('supports an empty baseline, preallocated ids, independent streams, and idempotent retry', async () => { const api = createFixtureApi({ empty: true, createFrameOrder: 'workspace-first' }) const initialSessions = await api.sessions.list(req({})) - const initialWorkspaces = await api.workspace.list(req({})) expect(initialSessions.result).toMatchObject({ ok: true, value: { items: [] } }) - expect(initialWorkspaces.result).toMatchObject({ ok: true, value: { items: [] } }) + expect(await readWorkspaceBaseline(api.workspaceRemote)).toEqual({ + items: [], + archivedSessionIds: [], + }) const made = await api.workspace.create(req({ path: '/tmp/fixture-workspaces/nova' })) if (!made.result.ok) throw new Error('workspace create failed') - const abort = new AbortController() - const framesPromise = collect(api.events.host(req({}), abort.signal), abort, frames => frames.length === 2) + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const hostFrames = collectValues( + api.remoteEvents(hostAbort.signal), + hostAbort, + frames => frames.length === 1, + ) + const workspaceFrames = collectValues( + api.workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.includes(sid('fx-preallocated'))), + ) await new Promise(resolve => setTimeout(resolve, 10)) const preallocated = sid('fx-preallocated') const created = await api.sessions.create(req({ @@ -759,15 +1304,17 @@ describe('createFixtureApi', () => { sessionId: preallocated, })) expect(created.result).toEqual({ ok: true, value: { sessionId: preallocated } }) - const frames = await framesPromise - expect(frames[0]).toMatchObject({ - type: 'host/workspace-changed', workspace: { sessionIds: [preallocated] }, + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', workspace: { sessionIds: [preallocated] }, }) - const added = frames[1] - if (added?.type !== 'host/session-added') throw new Error('session-added frame missing') - expect(added).toEqual({ - type: 'host/session-added', sessionId: preallocated, blank: true, - cwd: made.result.value.workspace.path, + const added = (await hostFrames)[0] + expect(added).toMatchObject({ + event: 'api-session/added', + args: [{ + sessionId: preallocated, + blank: true, + cwd: made.result.value.workspace.path, + }], }) const retried = await api.sessions.create(req({ @@ -798,9 +1345,8 @@ describe('createFixtureApi', () => { workspaceId: 'fx-ws-fixture' as WorkspaceId, }))).resolves.toMatchObject({ result: { ok: true, value: { sessionId } } }) - const workspaces = await api.workspace.list(req({})) - if (!workspaces.result.ok) throw new Error('workspace list failed') - expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + const workspaces = await readWorkspaceBaseline(api.workspaceRemote) + expect(workspaces.items[0]?.sessionIds).toContain(sessionId) }) it('reports a conflict without an existing cwd detail for an unrecorded cwd', async () => { @@ -834,10 +1380,10 @@ describe('createFixtureApi', () => { error: { code: 'workspace-attach-failed', details: { sessionId, workspaceId: 'fx-ws-fixture' } }, }) const listed = await api.sessions.list(req({})) - const workspaces = await api.workspace.list(req({})) - if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + const workspaces = await readWorkspaceBaseline(api.workspaceRemote) + if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.items.filter(item => item.sessionId === sessionId)).toHaveLength(1) - expect(workspaces.result.value.items[0]?.sessionIds).not.toContain(sessionId) + expect(workspaces.items[0]?.sessionIds).not.toContain(sessionId) const retried = await api.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId, @@ -857,10 +1403,10 @@ describe('createFixtureApi', () => { sessionId, })))).rejects.toThrow(/dropped session\.create response/) const listed = await dropped.sessions.list(req({})) - const workspaces = await dropped.workspace.list(req({})) - if (!listed.result.ok || !workspaces.result.ok) throw new Error('list failed') + const workspaces = await readWorkspaceBaseline(dropped.workspaceRemote) + if (!listed.result.ok) throw new Error('list failed') expect(listed.result.value.items.some(item => item.sessionId === sessionId)).toBe(true) - expect(workspaces.result.value.items[0]?.sessionIds).toContain(sessionId) + expect(workspaces.items[0]?.sessionIds).toContain(sessionId) await expect(dropped.sessions.create(req({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId, @@ -897,15 +1443,35 @@ describe('createFixtureApi', () => { // The failure was one-shot: the next call succeeds. const ok = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) expect(ok.result.ok).toBe(true) - // appendUser emits on the mux stream; appendSilent only lands in the log (lost frame). - const abort = new AbortController() - const seen: MuxFrame[] = [] - const consuming = (async () => { - for await (const envelope of api.events.mux(req({}), abort.signal)) seen.push(envelope.payload) - })() - await new Promise(resolve => setTimeout(resolve, 10)) + // A durable append without a live frame creates a detectable seq gap. + const gapAbort = new AbortController() + const gapIterator = api.sessionRemote.follow(sid('fx-alpha'), gapAbort.signal)[Symbol.asyncIterator]() + const opening = await gapIterator.next() + if (opening.done || opening.value.type !== 'snapshot') throw new Error('follow opening snapshot missing') hooks.appendSilent('fx-alpha', '静默丢帧') hooks.appendUser('fx-alpha', '正常直播') + await expect(gapIterator.next()).rejects.toThrow(/stream skipped seq/) + + // Reopening replaces the window with a complete snapshot containing both durable events. + const followAbort = new AbortController() + const controlAbort = new AbortController() + const followed: FixtureFollowFrame[] = [] + const controlled: FixtureControlFrame[] = [] + const following = (async () => { + for await (const frame of api.sessionRemote.follow(sid('fx-alpha'), followAbort.signal)) { + followed.push(frame) + } + })() + const controlling = (async () => { + for await (const frame of api.sessionRemote.control(controlAbort.signal)) controlled.push(frame) + })() + await new Promise(resolve => setTimeout(resolve, 10)) + await vi.waitFor(() => { + const snapshot = followed.find(frame => frame.type === 'snapshot') + const events = snapshot === undefined ? [] : historyEvents(snapshot.records) + expect(events.some(event => JSON.stringify(event.data).includes('静默丢帧'))).toBe(true) + expect(events.some(event => JSON.stringify(event.data).includes('正常直播'))).toBe(true) + }) hooks.appendTitle('fx-alpha', 'Fixture 修订标题') hooks.beginModelRetry('fx-alpha') hooks.scheduleModelRetry('fx-alpha') @@ -913,33 +1479,27 @@ describe('createFixtureApi', () => { hooks.beginModelRetry('fx-alpha') hooks.cancelModelRetryDuringBackoff('fx-alpha') await vi.waitFor(() => { - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('正常直播'))).toBe(true) - expect(seen.some(f => f.type === 'session/event' && (f.event as { type: string }).type === 'llm/retry')).toBe(true) - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('重试后的完整回复'))).toBe(true) - expect(seen.some(f => f.type === 'session/event' - && f.event.type === 'turn/end' - && f.event.data.reason.kind === 'aborted')).toBe(true) - expect(seen.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题')).toBe(true) + expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'llm/retry')).toBe(true) + expect(followed.some(frame => frame.type === 'event' && JSON.stringify(frame.event.data).includes('重试后的完整回复'))).toBe(true) + expect(followed.some(frame => frame.type === 'event' + && frame.event.type === 'turn/end' + && frame.event.data.reason.kind === 'aborted')).toBe(true) + expect(controlled.some(frame => frame.type === 'projection' + && frame.key === 'title' + && frame.value === 'Fixture 修订标题')).toBe(true) }) - expect(seen.some(f => f.type === 'session/event' && JSON.stringify(f.event.data).includes('静默丢帧'))).toBe(false) - const rawTitleIndex = seen.findIndex(f => f.type === 'session/event' && (f.event as { type: string }).type === 'session/title') - const titleControlIndex = seen.findIndex(f => f.type === 'session/projection' && f.key === 'title' && f.value === 'Fixture 修订标题') - expect(titleControlIndex).toBe(rawTitleIndex + 1) - // But history serves the silent event (the client's repull finds it). + expect(followed.some(frame => frame.type === 'event' && (frame.event as { type: string }).type === 'session/title')).toBe(true) + // Paging and resumed follow agree on the recovered durable event. const repull = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 5 })) if (!repull.result.ok) throw new Error('repull failed') - expect(JSON.stringify(repull.result.value.events)).toContain('静默丢帧') - // breakStreams force-ends BOTH stream kinds without the client abort. - const habort = new AbortController() - const hostConsuming = (async () => { - for await (const _ of api.events.host(req({}), habort.signal)) { /* drain */ } - })() + expect(JSON.stringify(repull.result.value.records)).toContain('静默丢帧') + // breakStreams force-ends follow and control without client aborts. await new Promise(resolve => setTimeout(resolve, 10)) hooks.breakStreams() - await consuming // returns because the stream broke, not because we aborted - await hostConsuming - expect(abort.signal.aborted).toBe(false) - expect(habort.signal.aborted).toBe(false) + await following + await controlling + expect(followAbort.signal.aborted).toBe(false) + expect(controlAbort.signal.aborted).toBe(false) }) it('paces the opt-in reasoning stress hook from an external interval', async () => { @@ -953,8 +1513,8 @@ describe('createFixtureApi', () => { expect(() => hooks.startReasoningChunkStorm('fx-alpha', 1, 1, 0)).toThrow(/reasoning interval/) const abort = new AbortController() try { - const streamed = collect(api.events.mux(req({}), abort.signal), abort, frames => frames.some(frame => ( - frame.type === 'session/event' + const streamed = collectValues(api.sessionRemote.follow(sid('fx-alpha'), abort.signal), abort, frames => frames.some(frame => ( + frame.type === 'event' && frame.event.type === 'assistant/chunk' && frame.event.data.chunk.type === 'reasoning-delta' && frame.event.data.chunk.text.includes('REASONING_STRESS_COMPLETE') @@ -973,7 +1533,7 @@ describe('createFixtureApi', () => { const frames = await streamed const deltas = frames.flatMap(frame => ( - frame.type === 'session/event' + frame.type === 'event' && frame.event.type === 'assistant/chunk' && frame.event.data.chunk.type === 'reasoning-delta' ? [frame.event.data.chunk.text] @@ -999,18 +1559,16 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { expect(() => (client as unknown as { doFetch(): Promise }).doFetch()).toThrow(/doFetch must be unreachable/) }) - it('mints request ids, taps all four full forms, and never touches doFetch', async () => { + it('mints request ids and taps unary request/response envelopes without touching doFetch', async () => { const client = new FixtureApiClient() const tapped: RpcMessage[] = [] client.subscribeEnvelopes(batch => tapped.push(...batch)) - const response = await client.sessions.list({}) + const response = await client.host.describe({}) expect(response.result.ok).toBe(true) - await client.respond({ type: 'client-response', rpcId: RpcId('r-x'), result: { ok: true, value: {} } }) await vi.waitFor(() => { const kinds = tapped.map(m => m.type) expect(kinds).toContain('client-request') expect(kinds).toContain('server-response') - expect(kinds).toContain('client-response') }) const request = tapped.find(m => m.type === 'client-request') const reply = tapped.find(m => m.type === 'server-response') @@ -1019,57 +1577,63 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('covers the whole unary dispatch table', async () => { const client = new FixtureApiClient() - expect((await client.sessions.search( + const sessions = createSessionClient(client.rpc) + const workspaces = createWorkspaceClient(client.rpc) + expect((await sessions.search( { query: 'fixture' }, new AbortController().signal, )).result.ok).toBe(true) - const created = await client.sessions.create({}) + const created = await sessions.create({}) if (!created.result.ok) throw new Error('create failed') const id = created.result.value.sessionId - expect((await client.sessions.history({ sessionId: id })).result.ok).toBe(true) - expect((await client.sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) - expect((await client.sessions.cancel({ sessionId: id })).result.ok).toBe(true) + expect((await sessions.history({ sessionId: id })).result.ok).toBe(true) + expect((await sessions.prompt({ sessionId: id, mode: 'queue', content: [{ type: 'text', text: '嗨' }] })).result.ok).toBe(true) + expect((await sessions.cancel({ sessionId: id })).result.ok).toBe(true) expect((await client.host.describe({})).result.ok).toBe(true) - expect((await client.workspace.list({})).result.ok).toBe(true) - const workspace = await client.workspace.create({ path: '/tmp/fixture-workspaces/via-client' }) + expect((await readWorkspaceBaseline(createWorkspaceRemote(client.rpc))).items).not.toHaveLength(0) + const workspace = await workspaces.create({ path: '/tmp/fixture-workspaces/via-client' }) if (!workspace.result.ok) throw new Error('workspace create failed') expect(workspace.result.value.workspace.title).toBe('via-client') const wsid = workspace.result.value.workspace.workspaceId - const renamed = await client.workspace.rename({ workspaceId: wsid, title: 'via-client-2' }) + const renamed = await workspaces.rename({ workspaceId: wsid, title: 'via-client-2' }) if (!renamed.result.ok) throw new Error('workspace rename failed') expect(renamed.result.value.workspace.title).toBe('via-client-2') - const attached = await client.sessions.create({ workspaceId: wsid }) + const attached = await sessions.create({ workspaceId: wsid }) if (!attached.result.ok) throw new Error('attached create failed') - const moved = await client.workspace.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) + const moved = await workspaces.insertSessionBefore({ workspaceId: wsid, sessionId: attached.result.value.sessionId }) if (!moved.result.ok) throw new Error('workspace move failed') expect(moved.result.value.workspace.sessionIds).toEqual([attached.result.value.sessionId]) - // Goal lifecycle over the fixture fold: create → edit → pause → resume → complete → clear; - // every mutation acknowledges with the NEW CAS ref (state rides the projection frames). - const goalCreated = await client.goals.create({ sessionId: id, objective: 'ship it' }) - if (!goalCreated.result.ok) throw new Error('goal create failed') - let ref = goalCreated.result.value.ref - expect(ref.revision).toBe(1) - const edited = await client.goals.edit({ sessionId: id, ref, objective: 'ship it v2' }) - if (!edited.result.ok) throw new Error('goal edit failed') - ref = edited.result.value.ref - const paused = await client.goals.pause({ sessionId: id, ref }) - if (!paused.result.ok) throw new Error('goal pause failed') - ref = paused.result.value.ref - const resumed = await client.goals.resume({ sessionId: id, ref }) - if (!resumed.result.ok) throw new Error('goal resume failed') - ref = resumed.result.value.ref - // A stale ref loses the CAS check. - expect((await client.goals.pause({ sessionId: id, ref: { ...ref, revision: 1 } })).result.ok).toBe(false) - const completed = await client.goals.complete({ sessionId: id, ref }) - if (!completed.result.ok) throw new Error('goal complete failed') - ref = completed.result.value.ref - // complete → complete is an invalid transition. - expect((await client.goals.complete({ sessionId: id, ref })).result.ok).toBe(false) - expect((await client.goals.clear({ sessionId: id, ref })).result).toEqual({ ok: true, value: { cleared: true } }) + }) - const goalHistory = await client.sessions.history({ sessionId: id }) + it('folds the goal lifecycle over the Goal Remotes', async () => { + const client = new FixtureApiClient() + const sessions = createSessionClient(client.rpc) + const created = await sessions.create({}) + if (!created.result.ok) throw new Error('create failed') + const id = created.result.value.sessionId + const goal = (endpoint: string, args: Record) => + client.rpc.call('/api', endpoint, { args: { agentId: id, ...args } }) + + // create → edit → pause → resume → complete → clear; each mutation advances the CAS + // revision by one (state rides the projection frames). + const goalCreated = await goal('goals/create', { request: { objective: 'ship it' } }) + if (!goalCreated.ok) throw new Error('goal create failed') + const { id: goalId, revision } = (goalCreated.value as { ref: { id: string; revision: number } }).ref + expect(revision).toBe(1) + const ref = (at: number) => ({ id: goalId, revision: at }) + expect((await goal('goals/edit', { ref: ref(1), request: { objective: 'ship it v2' } })).ok).toBe(true) + expect((await goal('goals/pause', { ref: ref(2) })).ok).toBe(true) + expect((await goal('goals/resume', { ref: ref(3) })).ok).toBe(true) + // A stale ref loses the CAS check. + expect((await goal('goals/pause', { ref: ref(1) })).ok).toBe(false) + expect((await goal('goals/complete', { ref: ref(4) })).ok).toBe(true) + // complete → complete is an invalid transition. + expect((await goal('goals/complete', { ref: ref(5) })).ok).toBe(false) + expect(await goal('goals/clear', { ref: ref(5) })).toEqual({ ok: true, value: ref(6) }) + + const goalHistory = await sessions.history({ sessionId: id }) if (!goalHistory.result.ok) throw new Error('goal history failed') - const goalEvents = goalHistory.result.value.events.map(entry => entry.event as unknown as { + const goalEvents = historyEvents(goalHistory.result.value.records).map(event => event as unknown as { type: string data: { operation?: string @@ -1088,21 +1652,40 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { search: '?fixture=empty&fixturePrompt=reject&fixtureFrames=workspace-first', }) const client = new FixtureApiClient() - await expect(client.sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) - const made = await client.workspace.create({ path: '/tmp/fixture-workspaces/query-workspace' }) + const sessions = createSessionClient(client.rpc) + const workspaces = createWorkspaceClient(client.rpc) + const workspaceRemote = createWorkspaceRemote(client.rpc) + await expect(sessions.list({})).resolves.toMatchObject({ result: { ok: true, value: { items: [] } } }) + const made = await workspaces.create({ path: '/tmp/fixture-workspaces/query-workspace' }) if (!made.result.ok) throw new Error('workspace create failed') - const abort = new AbortController() - const framesPromise = collect(client.events.host({}, abort.signal), abort, frames => frames.length === 2) + const hostAbort = new AbortController() + const workspaceAbort = new AbortController() + const hostFrames = collectValues( + openFixtureRemoteEvents(client.rpc, hostAbort.signal), + hostAbort, + frames => frames.length === 1, + ) + const workspaceFrames = collectValues( + workspaceRemote.follow(workspaceAbort.signal), + workspaceAbort, + frames => frames.some(frame => frame.type === 'upsert' + && frame.workspace.sessionIds.includes(sid('fx-query-session'))), + ) await new Promise(resolve => setTimeout(resolve, 10)) const sessionId = sid('fx-query-session') - const created = await client.sessions.create({ + const created = await sessions.create({ workspaceId: made.result.value.workspace.workspaceId, sessionId, }) expect(created.result).toMatchObject({ ok: true, value: { sessionId } }) - const frames = await framesPromise - expect(frames.map(frame => frame.type)).toEqual(['host/workspace-changed', 'host/session-added']) - const rejected = await client.sessions.prompt({ + expect((await workspaceFrames).at(-1)).toMatchObject({ + type: 'upsert', + workspace: { sessionIds: [sessionId] }, + }) + expect((await hostFrames)[0]).toMatchObject({ + event: 'api-session/added', + }) + const rejected = await sessions.prompt({ sessionId, mode: 'queue', content: [{ type: 'text', text: 'retain' }], @@ -1113,7 +1696,7 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { it('maps attach-failure and dropped-response query scenarios', async () => { vi.stubGlobal('location', { search: '?fixture&fixtureAttach=fail' }) const partial = new FixtureApiClient() - const partialResult = await partial.sessions.create({ + const partialResult = await createSessionClient(partial.rpc).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-partial'), }) @@ -1124,34 +1707,10 @@ describe('FixtureApiClient (protocol-level fake carrier)', () => { vi.stubGlobal('location', { search: '?fixture&fixtureSessionCreate=drop-response' }) const dropped = new FixtureApiClient() - await expect(dropped.sessions.create({ + await expect(createSessionClient(dropped.rpc).create({ workspaceId: 'fx-ws-fixture' as WorkspaceId, sessionId: sid('fx-query-dropped'), })).rejects.toThrow(/dropped session\.create response/) }) - it('fires onOpen at stream-iteration start and taps server-request full forms', async () => { - const client = new FixtureApiClient() - const tapped: RpcMessage[] = [] - client.subscribeEnvelopes(batch => tapped.push(...batch)) - const order: string[] = [] - const abort = new AbortController() - for await (const envelope of client.events.mux({}, abort.signal, () => order.push('open'))) { - order.push(envelope.payload.type) - abort.abort() - } - expect(order[0]).toBe('open') - expect(order[1]).toBe('session/subscribed') - await vi.waitFor(() => { - expect(tapped.some(m => m.type === 'server-request')).toBe(true) - }) - // Host stream side of the pair (same tap path). - const habort = new AbortController() - const hostOrder: string[] = [] - const hostIterator = client.events.host({}, habort.signal, () => hostOrder.push('open'))[Symbol.asyncIterator]() - const raced = await Promise.race([hostIterator.next(), new Promise<'idle'>(resolve => setTimeout(() => { resolve('idle') }, 50))]) - expect(hostOrder).toEqual(['open']) // established even though the host stream stays silent - habort.abort() - if (raced === 'idle') await hostIterator.return?.(undefined) - }) }) diff --git a/packages/client/connection/tests/node-half.host.spec.ts b/packages/client/connection/tests/node-half.host.spec.ts index 0b30ce6520..e1cb7abe83 100644 --- a/packages/client/connection/tests/node-half.host.spec.ts +++ b/packages/client/connection/tests/node-half.host.spec.ts @@ -1,7 +1,7 @@ /** Node half: registers the /api prefix route bridging to the api gateway. */ -import { EventEmitter, once } from 'node:events' +import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { PassThrough, Readable } from 'node:stream' +import { Readable } from 'node:stream' import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' @@ -10,7 +10,9 @@ import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { AttachmentStore } from '@deepseek-ai/dsh-attachment' import { RpcId, type ClientRequest } from '@deepseek-ai/dsh-host-apiproxy/api' import type { WebServer, WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' -import { API_PATH, apply, HOST_EVENTS_PATH, inject, MUX_EVENTS_PATH, type HostConnectionHandle } from '../src/index.ts' +import { API_PATH, apply, inject, type HostConnectionHandle } from '../src/index.ts' +import { DEFAULT_MAX_REQUEST_BODY_BYTES } from '../src/http-bridge.ts' +import { provideBrowserCredentials } from './browser-credentials.ts' /** Structural webServer fake recording both route registries. */ function fakeHttpServer( @@ -56,12 +58,19 @@ function fakeRawPost(headers: Record, url: string, body: string) } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { - const state: { status?: number; body?: unknown } = {} +function fakeResponse(): { + response: ServerResponse + state: { status?: number; headers?: Record; body?: unknown } +} { + const state: { status?: number; headers?: Record; body?: unknown } = {} const chunks: Buffer[] = [] const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number) { state.status = value; return this }, + writeHead(value: number, headers?: Record) { + state.status = value + if (headers !== undefined) state.headers = headers + return this + }, write(value: string | Uint8Array) { chunks.push(Buffer.from(value)); return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (typeof value === 'string' || value instanceof Uint8Array) chunks.push(Buffer.from(value)) @@ -77,20 +86,45 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[] upgrades: WebUpgradeRoute[] + connection: HostConnectionHandle dispose: () => Promise }> { const ctx = new Context() const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, upgrades, dispose: () => fiber.dispose() } + return { + routes, + upgrades, + connection: ctx.get('connection') as HostConnectionHandle, + dispose: () => fiber.dispose(), + } +} + +/** Exchange a service's process token for one authority-bound Cookie header. */ +function browserCookie(connection: HostConnectionHandle, authority: string): string { + const url = new URL(connection.authenticatedUrl(`http://${authority}`)) + const exchanged = fakeResponse() + connection.authorizeIndex( + fakeRequest({ host: authority }, `${url.pathname}${url.search}`), + exchanged.response, + ) + const setCookie = exchanged.state.headers?.['set-cookie'] + if (setCookie === undefined) throw new Error('browser token exchange did not set a cookie') + return setCookie.split(';', 1)[0]! } describe('connection node half', () => { - it('fails loud when the carrier cap cannot hold the configured image batch', () => { + it('reserves enough default carrier capacity for the 200 MiB image batch', () => { + expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBe(300 * 1024 * 1024) + expect(DEFAULT_MAX_REQUEST_BODY_BYTES).toBeGreaterThan(Math.ceil(200 * 1024 * 1024 * 4 / 3) + 1024 * 1024) + }) + + it('fails loud when the carrier cap cannot hold the configured image batch', async () => { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) @@ -98,8 +132,8 @@ describe('connection node half', () => { imageLimits: { maxMessageImageBytes: 20 * 1024 * 1024 }, } as AttachmentStore) ctx.provide('apiProxy', {} as ApiProxy) - expect(() => { apply(ctx, { maxRequestBodyBytes: 1024 }) }) - .toThrow(/must be at least .* aggregate image limit/) + await expect(apply(ctx, { maxRequestBodyBytes: 1024 })) + .rejects.toThrow(/must be at least .* aggregate image limit/) expect(routes).toHaveLength(0) }) @@ -107,6 +141,7 @@ describe('connection node half', () => { const routes: WebRoute[] = [] const upgrades: WebUpgradeRoute[] = [] const ctx = new Context() + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, upgrades) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) @@ -115,41 +150,16 @@ describe('connection node half', () => { expect(upgrades).toHaveLength(0) }) - it('registers one HTTP route plus one upgrade route per downlink and removes all three with the fiber', async () => { + it('registers only the HTTP route and removes it with the fiber', async () => { const { routes, upgrades, dispose } = await mounted() expect(routes).toHaveLength(1) expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) - expect(upgrades.map(route => route.path)).toEqual([MUX_EVENTS_PATH, HOST_EVENTS_PATH]) + expect(upgrades).toHaveLength(0) await dispose() expect(routes).toHaveLength(0) expect(upgrades).toHaveLength(0) }) - it('requires WebSocket upgrade for network GETs to either event path', async () => { - const { routes, dispose } = await mounted() - for (const path of [MUX_EVENTS_PATH, HOST_EVENTS_PATH]) { - const { response, state } = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }, path), response) - expect(state.status).toBe(426) - expect(state.body).toBe('upgrade required') - } - await dispose() - }) - - it('rejects an untrusted WebSocket upgrade before protocol negotiation', async () => { - const { upgrades, dispose } = await mounted() - const socket = new PassThrough() - const chunks: Buffer[] = [] - socket.on('data', (chunk: Buffer) => { chunks.push(chunk) }) - const ended = once(socket, 'end') - await upgrades[0]!.handler(fakeRequest({ - host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin', - }, MUX_EVENTS_PATH), socket, Buffer.alloc(0)) - await ended - expect(Buffer.concat(chunks).toString()).toContain('HTTP/1.1 403 Forbidden') - await dispose() - }) - it('refuses an untrusted Host on any /api path before the bridge runs', async () => { const { routes, dispose } = await mounted() const { response, state } = fakeResponse() @@ -161,61 +171,83 @@ describe('connection node half', () => { await dispose() }) - it('pins privileged methods to loopback even for a declared trusted authority', async () => { - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) - // The privileged set: native dialogs plus the whole settings/credential - // configuration plane, reads included, plus the one method that makes the - // host fetch a caller-chosen URL. The same declared authority reaches - // ordinary reads (carrier-level 404 from the empty proxy proves the fence - // passed), but each privileged method stays loopback-only and 403s. - for (const method of [ + it('requires the same browser session for every method on every trusted authority', async () => { + const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const methods = [ 'host.pickDirectory', 'host.openPath', - 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', - 'credentials.describe', 'credentials.set', 'credentials.unset', - 'llm.discoverModels', - // A composition names the plugins a session runs: reading one is - // reconnaissance, and copy/remove/openDocument manage the roster and - // drive the host desktop. - 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', - ]) { + 'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set', + 'llm.discoverModels', 'llm.models', 'agentPreset.openDocument', + ] + for (const method of methods) { const denied = fakeResponse() - await routes[0]!.handler( - fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), - denied.response, - ) - expect(denied.state.status).toBe(403) - expect(denied.state.body).toBe('forbidden') + await routes[0]!.handler(fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`), denied.response) + expect([method, denied.state.status, denied.state.body]).toEqual([method, 401, 'unauthorized']) } - const read = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response) - expect(read.state.status).not.toBe(403) + + const cookie = browserCookie(connection, 'harness.example') + for (const method of methods) { + const allowed = fakeResponse() + await routes[0]!.handler( + fakeRequest({ host: 'harness.example', cookie }, `${API_PATH}/${method}`), + allowed.response, + ) + expect([method, allowed.state.status]).toEqual([method, 404]) + } + + const forged = fakeResponse() + await routes[0]!.handler(fakeRequest({ host: 'localhost:3080' }), forged.response) + expect(forged.state).toMatchObject({ status: 401, body: 'unauthorized' }) await dispose() }) it('passes loopback and declared-authority requests through to the bridge', async () => { - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) + const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] }) // Loopback, no browser markers (curl shape): the fence passes; the carrier // answers 404 for a GET unary path — proof the bridge ran. const loopback = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response) + await routes[0]!.handler(fakeRequest({ + host: '127.0.0.1:3080', + cookie: browserCookie(connection, '127.0.0.1:3080'), + }), loopback.response) expect(loopback.state.status).toBe(404) // An all-interfaces composition derives port-less LAN IP literals, which // pass markerless curl on any port. const lan = fakeResponse() - await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response) + await routes[0]!.handler(fakeRequest({ + host: '192.168.1.5:3080', + cookie: browserCookie(connection, '192.168.1.5:3080'), + }), lan.response) expect(lan.state.status).toBe(404) // Declared public authority, same-origin browser shape. const declared = fakeResponse() await routes[0]!.handler(fakeRequest({ - host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin', + host: 'harness.example:3080', + origin: 'http://harness.example:3080', + 'sec-fetch-site': 'same-origin', + cookie: browserCookie(connection, 'harness.example:3080'), }), declared.response) expect(declared.state.status).toBe(404) await dispose() }) + it('shares its configured trust and authentication policy with sibling routes', async () => { + const { connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + const loopback = fakeRequest({ host: '127.0.0.1:3080' }) + const declared = fakeRequest({ host: 'harness.example' }) + + expect(connection.requestRejection(loopback)).toBe(401) + expect(connection.requestRejection(declared)).toBe(401) + expect(connection.requestRejection(fakeRequest({ + host: 'harness.example', + cookie: browserCookie(connection, 'harness.example'), + }))).toBeUndefined() + await dispose() + }) + it('provides a disposable dedicated RPC channel without requiring apiProxy', async () => { const ctx = new Context() const routes: WebRoute[] = [] + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -227,7 +259,7 @@ describe('connection node half', () => { const remove = connection.rpc.handle('/rpc', async (endpoint, payload) => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } - }, { authority: 'trusted-host' }) + }) const route = routes.find(candidate => candidate.path === '/rpc') expect(route).toBeDefined() @@ -238,7 +270,10 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, } const result = fakeResponse() - await route!.handler(fakePost({ host: '127.0.0.1:3080' }, '/rpc/goals/create', request), result.response) + await route!.handler(fakePost({ + host: '127.0.0.1:3080', + cookie: browserCookie(connection, '127.0.0.1:3080'), + }, '/rpc/goals/create', request), result.response) expect(result.state.status).toBe(200) expect(JSON.parse(String(result.state.body))).toEqual({ type: 'server-response', @@ -250,9 +285,8 @@ describe('connection node half', () => { payload: { args: { agentId: 'agent-1' } }, }]) - expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }), { - authority: 'trusted-host', - })).toThrow(/duplicate route/) + expect(() => connection.rpc.handle('/rpc', async () => ({ ok: true, value: null }))) + .toThrow(/duplicate route/) await remove() expect(routes.map(candidate => candidate.path)).toEqual([API_PATH]) await fiber.dispose() @@ -262,6 +296,7 @@ describe('connection node half', () => { it('dispatches claimed /api endpoints before the API Proxy fallback and withdraws the claim', async () => { const ctx = new Context() const routes: WebRoute[] = [] + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) @@ -275,19 +310,16 @@ describe('connection node half', () => { calls.push({ endpoint, payload }) return { ok: true, value: { accepted: true } } }, - { authority: 'trusted-host' }, ) expect(() => connection.rpc.intercept( '/api', () => true, async () => ({ ok: true, value: null }), - { authority: 'trusted-host' }, )).toThrow('already has an interceptor') expect(() => connection.rpc.intercept( '/rpc' as '/api', () => true, async () => ({ ok: true, value: null }), - { authority: 'trusted-host' }, )).toThrow('invalid shared RPC channel') const route = routes.find(candidate => candidate.path === API_PATH)! const request: ClientRequest = { @@ -298,7 +330,10 @@ describe('connection node half', () => { } const claimed = fakeResponse() - await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), claimed.response) + const loopbackCookie = browserCookie(connection, '127.0.0.1:3080') + await route.handler(fakePost({ + host: '127.0.0.1:3080', cookie: loopbackCookie, + }, '/api/goals/create', request), claimed.response) expect(JSON.parse(String(claimed.state.body))).toEqual({ type: 'server-response', rpcId: 'rpc-shared', @@ -315,31 +350,38 @@ describe('connection node half', () => { expect(calls).toHaveLength(1) const unclaimed = fakeResponse() - await route.handler(fakeRequest({ host: '127.0.0.1:3080' }, '/api/session.list'), unclaimed.response) + await route.handler(fakeRequest({ + host: '127.0.0.1:3080', cookie: loopbackCookie, + }, '/api/session.list'), unclaimed.response) expect(unclaimed.state.status).toBe(404) await remove() const withdrawn = fakeResponse() - await route.handler(fakePost({ host: '127.0.0.1:3080' }, '/api/goals/create', request), withdrawn.response) + await route.handler(fakePost({ + host: '127.0.0.1:3080', cookie: loopbackCookie, + }, '/api/goals/create', request), withdrawn.response) expect(withdrawn.state.status).toBe(404) expect(calls).toHaveLength(1) - const removeLoopback = connection.rpc.intercept( + const removeAuthenticated = connection.rpc.intercept( '/api', endpoint => endpoint === 'goals/create', async () => ({ ok: true, value: null }), - { authority: 'loopback' }, ) - const loopbackOnly = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/api/goals/create', request), loopbackOnly.response) - expect(loopbackOnly.state.status).toBe(403) - await removeLoopback() + const declared = fakeResponse() + await route.handler(fakePost({ + host: 'harness.example', + cookie: browserCookie(connection, 'harness.example'), + }, '/api/goals/create', request), declared.response) + expect(declared.state.status).toBe(200) + await removeAuthenticated() await fiber.dispose() }) it('applies the configured trust fence and JSON envelope checks to generic channels', async () => { const ctx = new Context() const routes: WebRoute[] = [] + provideBrowserCredentials(ctx) ctx.provide('webServer', fakeHttpServer(routes, []) as WebServer) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.example'] }) await fiber.await() @@ -347,17 +389,23 @@ describe('connection node half', () => { const remove = connection.rpc.handle('/rpc', async (endpoint) => { if (endpoint === 'fail') throw new Error('handler broke') return { ok: true, value: null } - }, { - authority: 'trusted-host', }) const route = routes.find(candidate => candidate.path === '/rpc')! + const harnessHeaders = { + host: 'harness.example', + cookie: browserCookie(connection, 'harness.example'), + } const denied = fakeResponse() await route.handler(fakePost({ host: 'other.example' }, '/rpc/goals/create', {}), denied.response) expect(denied.state).toMatchObject({ status: 403, body: 'forbidden' }) + const unauthenticated = fakeResponse() + await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', {}), unauthenticated.response) + expect(unauthenticated.state).toMatchObject({ status: 401, body: 'unauthorized' }) + const methodMismatch = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', { + await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', { type: 'client-request', rpcId: 'rpc-bad', method: 'other', payload: {}, }), methodMismatch.response) expect(JSON.parse(String(methodMismatch.state.body))).toMatchObject({ @@ -366,12 +414,12 @@ describe('connection node half', () => { }) for (const [request, status] of [ - [fakeRequest({ host: 'harness.example' }, '/rpc/goals/create'), 404], - [fakePost({ host: 'harness.example' }, '/outside/goals/create', {}), 404], - [fakePost({ host: 'harness.example' }, '/rpc/goals//create', {}), 404], - [fakeRawPost({ host: 'harness.example' }, '/rpc/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], - [fakeRawPost({ host: 'harness.example', 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], + [fakeRequest(harnessHeaders, '/rpc/goals/create'), 404], + [fakePost(harnessHeaders, '/outside/goals/create', {}), 404], + [fakePost(harnessHeaders, '/rpc/goals//create', {}), 404], + [fakeRawPost(harnessHeaders, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ ...harnessHeaders, 'content-type': 'text/plain' }, '/rpc/goals/create', '{}'), 415], + [fakeRawPost({ ...harnessHeaders, 'content-type': 'application/json; charset=utf-8' }, '/rpc/goals/create', '{'), 400], ] as const) { const response = fakeResponse() await route.handler(request, response.response) @@ -384,7 +432,7 @@ describe('connection node half', () => { [null, 'invalid-request'], ] as const) { const response = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/rpc/goals/create', body), response.response) + await route.handler(fakePost(harnessHeaders, '/rpc/goals/create', body), response.response) expect(JSON.parse(String(response.state.body))).toMatchObject({ rpcId, result: { ok: false, error: { code: 'bad-request' } }, @@ -392,28 +440,15 @@ describe('connection node half', () => { } const failed = fakeResponse() - await route.handler(fakePost({ host: 'harness.example' }, '/rpc/fail', { + await route.handler(fakePost(harnessHeaders, '/rpc/fail', { type: 'client-request', rpcId: 'rpc-fail', method: 'fail', payload: {}, }), failed.response) expect(failed.state).toMatchObject({ status: 500, body: 'handler failure: Error: handler broke' }) - expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }), { - authority: 'loopback', - })).toThrow('invalid or reserved RPC channel') - expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }), { - authority: 'loopback', - })).toThrow('invalid or reserved RPC channel') - - const removeLoopback = connection.rpc.handle('/loopback', async () => ({ ok: true, value: null }), { - authority: 'loopback', - }) - const loopbackRoute = routes.find(candidate => candidate.path === '/loopback')! - const publicResponse = fakeResponse() - await loopbackRoute.handler(fakePost({ host: 'harness.example' }, '/loopback/read', { - type: 'client-request', rpcId: 'rpc-public', method: 'read', payload: {}, - }), publicResponse.response) - expect(publicResponse.state.status).toBe(403) - await removeLoopback() + expect(() => connection.rpc.handle('/api', async () => ({ ok: true, value: null }))) + .toThrow('invalid or reserved RPC channel') + expect(() => connection.rpc.handle('api3', async () => ({ ok: true, value: null }))) + .toThrow('invalid or reserved RPC channel') await remove() await fiber.dispose() }) @@ -439,10 +474,16 @@ describe('connection node half over a real HTTP server', () => { } /** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */ - function call(port: number, method: string, host: string): Promise { + function call(port: number, method: string, host: string, cookie?: string): Promise { return new Promise((resolve, reject) => { const request = httpRequest( - { host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } }, + { + host: '127.0.0.1', + port, + path: `${API_PATH}/${method}`, + method: 'GET', + headers: { host, ...cookie === undefined ? {} : { cookie } }, + }, (response) => { response.resume() response.on('end', () => { resolve(response.statusCode ?? 0) }) @@ -453,40 +494,37 @@ describe('connection node half over a real HTTP server', () => { }) } - it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => { - // The fence's input is a real IncomingMessage parsed by Node from the - // wire, not a hand-assembled object: the Host header a LAN browser sends - // is exactly what decides loopback-only here, so the boundary is asserted - // against the parse the server actually performs. - const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] }) + it('requires authentication uniformly over a real HTTP request', async () => { + // A real IncomingMessage pins the exploit boundary: a client-controlled + // Host naming loopback passes the rebinding fence but never authenticates. + const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] }) const { port, close } = await serve(routes) try { - // Reads are as privileged as writes: describe returns the exposed - // configuration, and credentials.describe probes arbitrary env-var names. - for (const method of [ + const methods = [ 'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate', 'credentials.describe', 'credentials.set', 'credentials.unset', 'host.pickDirectory', 'host.openPath', - // Carries a draft credential and turns the host into a fetcher for a - // URL the caller picked: an anonymous LAN caller must not reach it. 'llm.discoverModels', - 'agentPreset.read', 'agentPreset.copy', 'agentPreset.openDocument', 'agentPreset.remove', - ]) { - expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403]) + 'agentPreset.openDocument', + 'llm.providers', 'llm.models', + ] + for (const method of methods) { + expect([method, await call(port, method, 'localhost')]).toEqual([method, 401]) + expect([method, await call(port, method, 'harness.example')]).toEqual([method, 401]) } - // The model catalog stays reachable for the same authority: a LAN - // client's model picker needs it, and it carries no key or endpoint - // state (404 is the empty proxy's carrier answer — the fence passed). - // `agentPreset.list` joins the model catalog for the same reason: ids and - // trust only, and a LAN client's preset picker needs it. `select` is - // reachable too: `session.create` already takes an `agentPreset`, and the - // deployment's own default already carries bash, so pinning the switch - // would be a fence beside an open gate. - for (const method of ['llm.providers', 'llm.models', 'agentPreset.list', 'agentPreset.select']) { - expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404]) + expect(await call(port, 'settings.describe', 'other.example')).toBe(403) + + const declaredCookie = browserCookie(connection, 'harness.example') + for (const method of methods) { + expect([method, await call(port, method, 'harness.example', declaredCookie)]).toEqual([method, 404]) } - // Loopback reaches everything, configuration included. - expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404) + const loopbackAuthority = `127.0.0.1:${String(port)}` + expect(await call( + port, + 'settings.describe', + loopbackAuthority, + browserCookie(connection, loopbackAuthority), + )).toBe(404) } finally { await close() await dispose() diff --git a/packages/client/connection/tests/websocket-downlink.host.spec.ts b/packages/client/connection/tests/websocket-downlink.host.spec.ts deleted file mode 100644 index fecd7ea224..0000000000 --- a/packages/client/connection/tests/websocket-downlink.host.spec.ts +++ /dev/null @@ -1,308 +0,0 @@ -import { once } from 'node:events' -import { createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import { afterEach, describe, expect, it, vi } from 'vitest' -import WebSocket from 'ws' -import type { - ApiProxy, HostFrame, MuxFrame, RpcRequest, ServerRequest, -} from '@deepseek-ai/dsh-host-apiproxy/api' -import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api' -import { HOST_EVENTS_PATH, MUX_EVENTS_PATH } from '../src/api-path.ts' -import { WebSocketDownlinks } from '../src/websocket-downlink.ts' - -type MuxSource = (signal: AbortSignal) => AsyncIterable> -type HostSource = (signal: AbortSignal) => AsyncIterable> - -const running: (() => Promise)[] = [] - -afterEach(async () => { - await Promise.all(running.splice(0).map(close => close())) -}) - -function untilAbort(signal: AbortSignal): Promise { - if (signal.aborted) return Promise.resolve() - return new Promise((resolve) => { - signal.addEventListener('abort', () => { resolve() }, { once: true }) - }) -} - -async function * idle(signal: AbortSignal): AsyncGenerator> { - await untilAbort(signal) -} - -function api(mux: MuxSource, host: HostSource): ApiProxy { - return { - events: { - mux: (_request, signal) => mux(signal), - host: (_request, signal) => host(signal), - }, - } as ApiProxy -} - -async function serve(downlinks: WebSocketDownlinks): Promise<{ - origin: string - close: () => Promise -}> { - const server = createServer() - server.on('upgrade', (request, socket, head) => { - const pathname = new URL(request.url ?? '/', 'http://dsh.internal').pathname - if (pathname === MUX_EVENTS_PATH) downlinks.handleMux(request, socket, head) - else if (pathname === HOST_EVENTS_PATH) downlinks.handleHost(request, socket, head) - else socket.destroy() - }) - await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) - const port = (server.address() as AddressInfo).port - return { - origin: `ws://127.0.0.1:${String(port)}`, - close: async () => { - await downlinks.close() - await new Promise(resolve => server.close(() => { resolve() })) - }, - } -} - -function read(socket: WebSocket): Promise { - return once(socket, 'message').then(([data]) => JSON.parse(String(data)) as ServerRequest) -} - -async function acceptedSocket(downlinks: WebSocketDownlinks): Promise { - const server = (downlinks as unknown as { server: { clients: Set } }).server - let accepted: WebSocket | undefined - await vi.waitFor(() => { - accepted = server.clients.values().next().value - expect(accepted).toBeDefined() - }) - return accepted as WebSocket -} - -describe('WebSocket downlinks', () => { - it('carries mux and host over independent downstream sockets and cancels each source on close', async () => { - let muxAborted = false - let hostAborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - yield { - rpcId: RpcId('mux-1'), - payload: { type: 'session/subscribed', sessionId: 'session-1' as never, lastSeq: 4 }, - } - await untilAbort(signal) - } finally { - muxAborted = true - } - }, - async function * (signal) { - try { - yield { rpcId: RpcId('host-1'), payload: { type: 'host/remote-event', event: 'commands/change', args: [] } } - await untilAbort(signal) - } finally { - hostAborted = true - } - }, - )) - const host = await serve(downlinks) - running.push(host.close) - - const mux = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - const hostSocket = new WebSocket(`${host.origin}${HOST_EVENTS_PATH}`) - const muxFrame = read(mux) - const hostFrame = read(hostSocket) - expect(await muxFrame).toEqual({ - type: 'server-request', - rpcId: 'mux-1', - method: 'session/subscribed', - payload: { type: 'session/subscribed', sessionId: 'session-1', lastSeq: 4 }, - }) - expect(await hostFrame).toEqual({ - type: 'server-request', - rpcId: 'host-1', - method: 'host/remote-event', - payload: { type: 'host/remote-event', event: 'commands/change', args: [] }, - }) - - const muxClosed = once(mux, 'close') - const hostClosed = once(hostSocket, 'close') - mux.close() - hostSocket.close() - await Promise.all([muxClosed, hostClosed]) - await vi.waitFor(() => { - expect(muxAborted).toBe(true) - expect(hostAborted).toBe(true) - }) - }) - - it('rejects client messages because upstream remains HTTP', async () => { - let aborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - aborted = true - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const closed = once(socket, 'close') - socket.send('upstream payload') - const [code, reason] = await closed as [number, Buffer] - expect(code).toBe(1008) - expect(String(reason)).toBe('downlink only') - await vi.waitFor(() => { expect(aborted).toBe(true) }) - }) - - it('sends stream/error before closing when a source fails', async () => { - const downlinks = new WebSocketDownlinks(api( - async function * () { - throw new Error('mux source failed') - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - const failure = read(socket) - const closed = once(socket, 'close') - expect((await failure).payload).toEqual({ - type: 'stream/error', - error: { code: 'internal', message: 'Error: mux source failed', details: {} }, - }) - await closed - }) - - it('aborts the source when an accepted socket reports a transport error', async () => { - let aborted = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - aborted = true - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const accepted = await acceptedSocket(downlinks) - const closed = once(socket, 'close') - accepted.emit('error', new Error('transport failed')) - await closed - expect(aborted).toBe(true) - }) - - it('drops a source frame that races after the client has closed', async () => { - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - let finish!: () => void - const finished = new Promise((resolve) => { finish = resolve }) - let sourceSignal: AbortSignal | undefined - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - sourceSignal = signal - try { - await gate - yield { - rpcId: RpcId('late'), - payload: { type: 'session/subscribed', sessionId: 'session-late' as never, lastSeq: 0 }, - } - } finally { - finish() - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const closed = once(socket, 'close') - socket.close() - await closed - await vi.waitFor(() => { expect(sourceSignal?.aborted).toBe(true) }) - release() - await finished - }) - - it('contains socket send callback failures and closes the downlink', async () => { - let release!: () => void - const gate = new Promise((resolve) => { release = resolve }) - const downlinks = new WebSocketDownlinks(api( - async function * () { - await gate - yield { - rpcId: RpcId('send-failure'), - payload: { type: 'session/subscribed', sessionId: 'session-send' as never, lastSeq: 0 }, - } - }, - idle, - )) - const host = await serve(downlinks) - running.push(host.close) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - const accepted = await acceptedSocket(downlinks) - const send = vi.spyOn(accepted, 'send').mockImplementation((( - _data: unknown, - optionsOrCallback?: unknown, - callback?: (error?: Error) => void, - ) => { - const done = typeof optionsOrCallback === 'function' - ? optionsOrCallback as (error?: Error) => void - : callback - done?.(new Error('socket send failed')) - }) as WebSocket['send']) - const closed = once(socket, 'close') - release() - await closed - expect(send).toHaveBeenCalledTimes(2) - send.mockRestore() - }) - - it('rejects when its acceptor has already closed', async () => { - const downlinks = new WebSocketDownlinks(api(idle, idle)) - await downlinks.close() - await expect(downlinks.close()).rejects.toThrow('The server is not running') - }) - - it('waits for source cleanup before teardown resolves', async () => { - let cleanupStarted!: () => void - const started = new Promise((resolve) => { cleanupStarted = resolve }) - let releaseCleanup!: () => void - const cleanupGate = new Promise((resolve) => { releaseCleanup = resolve }) - let cleaned = false - const downlinks = new WebSocketDownlinks(api( - async function * (signal) { - try { - await untilAbort(signal) - } finally { - cleanupStarted() - await cleanupGate - cleaned = true - } - }, - idle, - )) - const host = await serve(downlinks) - const socket = new WebSocket(`${host.origin}${MUX_EVENTS_PATH}`) - await once(socket, 'open') - let closed = false - const closing = host.close().then(() => { closed = true }) - try { - await started - expect(closed).toBe(false) - releaseCleanup() - await closing - expect(cleaned).toBe(true) - } finally { - releaseCleanup() - await closing - } - }) -}) diff --git a/packages/client/connection/tsconfig.client.json b/packages/client/connection/tsconfig.client.json index 4d8621e270..c40e2cbadb 100644 --- a/packages/client/connection/tsconfig.client.json +++ b/packages/client/connection/tsconfig.client.json @@ -27,6 +27,9 @@ { "path": "../../core/session" }, + { + "path": "../../todo/tool-todo" + }, { "path": "../../core/tools" }, diff --git a/packages/client/connection/tsconfig.host.json b/packages/client/connection/tsconfig.host.json index 8e16ec834a..ad9bacc704 100644 --- a/packages/client/connection/tsconfig.host.json +++ b/packages/client/connection/tsconfig.host.json @@ -8,18 +8,21 @@ "files": [ "src/api-path.ts", "src/api-request-trust.ts", + "src/browser-auth.ts", "src/http-bridge.ts", "src/index.ts", "src/invariant.ts", "src/loopback-hostname.ts", "src/rpc-host.ts", - "src/rpc.ts", - "src/websocket-downlink.ts" + "src/rpc.ts" ], "references": [ { "path": "../../attachment/attachment" }, + { + "path": "../../credentials/credentials" + }, { "path": "../../host/apiproxy" }, diff --git a/packages/client/hmr/README.i18n.yaml b/packages/client/hmr/README.i18n.yaml index 07bcf1d6a2..047249b5b7 100644 --- a/packages/client/hmr/README.i18n.yaml +++ b/packages/client/hmr/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/hmr/README.md -README.md: c355595dd53ddcb74be629a6d5e730c6c5fcebbf -README.zh.md: 6ed4d0e79cb755f84784823749994b448ff209b8 +README.md: de420ef2c809ccc13feea320ae3ffda3720f82a3 +README.zh.md: c7066e369b38fa3ffda6888831bba7780c5da8af diff --git a/packages/client/hmr/README.md b/packages/client/hmr/README.md index c355595dd5..de420ef2c8 100644 --- a/packages/client/hmr/README.md +++ b/packages/client/hmr/README.md @@ -1,14 +1,106 @@ +--- +description: "Development-only hot reload for browser client plugins: rebuilding a plugin bundle swaps the running plugin in place, for developers iterating on the web GUI." +kind: "package-reference" +--- + # @deepseek-ai/dsh-client-hmr English | [中文](README.zh.md) -Hot reload for script-loaded client plugins. The web bundle mounts the row unconditionally; without a rebuild watcher (`pnpm run dev:web`) rewriting client bundles, the poll observes no changes and the chain stays idle. +## Summary -The browser half subscribes to the system SSE channel (`GET /plugins/events`) and reloads one plugin per `rebuilt` frame through a serialized queue. The sequence per frame — `invalidate`, `prefetch` (load and register the new bundle while the old fiber still serves), `registry.delete` (before the fiber: a bare fiber dispose trips the vendored Loader's self-dispose branch, which would mark the entry disabled), drain the old fiber, delete `entry.fiber`, remove owned `` } + case 'html': + return { placement: row.placement, markup: row.html } + default: + return assertNever(row) + } +} + +/** Insert `markup` into `html` at `at`. */ +function splice(html: string, at: number, markup: string): string { + return `${html.slice(0, at)}${markup}${html.slice(at)}` +} + +/** + * Tail script settling the boot-readiness deferred (`__DSH_BOOT_READY__`): + * the client entry awaits its `.promise` before reading any injected state. + * Whichever side runs first creates the deferred (`??=`), so a bootstrap that + * applies the table asynchronously installs it ahead of the entry module and + * settles it after the last row; the served form below creates and resolves + * it in one statement, because every row is already in the document text. + */ +const READY_MARKUP = '' + +/** + * Render rows into an index.html body: head rows immediately after the + * opening head tag, body rows immediately after the opening body tag, each + * group in table order, and the boot-readiness tail after the last body row. + * @param html - the raw index.html body. + * @param rows - the collected injection table. + * @returns the html with every row rendered. + */ +export function renderIndexInjections(html: string, rows: readonly IndexInjection[]): string { + let head = '' + let body = '' + for (const row of rows) { + const rendered = renderRow(row) + if (rendered.placement === 'head') head += rendered.markup + else body += rendered.markup + } + body += READY_MARKUP + let out = html + if (head !== '') { + const open = /]*)?>/i.exec(out) + // Headless fixture pages may lack ; prepending keeps the rows ahead + // of every document script. + out = open === null ? `${head}${out}` : splice(out, open.index + open[0].length, head) + } + if (body !== '') { + const open = /]*)?>/i.exec(out) + // Body-less fragments receive the rows at the end, where the HTML parser + // has already synthesized a body. + out = open === null ? `${out}${body}` : splice(out, open.index + open[0].length, body) + } + return out +} diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index 2cbd285856..b7ac506b17 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -15,7 +15,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' import Loader from '@deepseek-ai/cordis-plugin-loader' import Include from '@deepseek-ai/cordis-plugin-include' -import HttpServer from '../src/index.ts' +import HttpServer, { renderIndexInjections } from '../src/index.ts' let root: string | undefined let context: Context | undefined @@ -28,7 +28,7 @@ afterEach(async () => { }) /** Write a cordis.yml with one webserver row, then boot it through the real Loader. */ -async function loadComposition(port = 0): Promise { +async function loadComposition(port = 0, gzip = false): Promise { root = await mkdtemp(join(tmpdir(), 'dsh-webserver-loader-')) const configPath = join(root, 'cordis.yml') await writeFile(configPath, [ @@ -36,6 +36,13 @@ async function loadComposition(port = 0): Promise { ' config:', " host: '127.0.0.1'", ` port: ${String(port)}`, + ...(gzip + ? [ + ' compression: gzip', + ' compressionLevel: 1', + ' compressionThresholdBytes: 16', + ] + : []), '', ].join('\n')) @@ -62,9 +69,13 @@ async function loadComposition(port = 0): Promise { } /** GET (by default) one path against the running server; returns status plus a body prefix. */ -async function request(port: number, path: string, init?: RequestInit): Promise<{ status: number; body: string }> { +async function request( + port: number, + path: string, + init?: RequestInit, +): Promise<{ status: number; body: string; headers: Headers }> { const response = await fetch(`http://127.0.0.1:${String(port)}${path}`, init) - return { status: response.status, body: (await response.text()).slice(0, 80) } + return { status: response.status, body: (await response.text()).slice(0, 80), headers: response.headers } } /** Open one raw upgrade request and return after the handler writes its response. */ @@ -86,6 +97,98 @@ async function upgrade(port: number, path: string): Promise { + it('applies gzip only to eligible socket-backed HTTP responses', { timeout: 60_000 }, async () => { + expect(HttpServer.Config({ host: '127.0.0.1', port: 0 })).toEqual({ + host: '127.0.0.1', + port: 0, + compression: 'none', + compressionLevel: 1, + compressionThresholdBytes: 1024, + }) + expect(() => HttpServer.Config({ + host: '127.0.0.1', port: 0, compressionLevel: 10, + })).toThrow() + + const loaded = await loadComposition(0, true) + const server = loaded.webServer + const body = 'compressible response '.repeat(8) + server.register({ + kind: 'exact', + path: '/text', + handler: (_req, res) => { + res.writeHead(200, { + 'content-type': 'text/plain; charset=utf-8', + 'content-length': String(Buffer.byteLength(body)), + }) + res.end(body) + }, + }) + server.register({ + kind: 'exact', + path: '/stream', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'application/json' }) + res.write(body.slice(0, 40)) + res.end(body.slice(40)) + }, + }) + server.register({ + kind: 'exact', + path: '/small', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'text/plain', 'content-length': '5' }) + res.end('small') + }, + }) + server.register({ + kind: 'exact', + path: '/events', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'text/event-stream' }) + res.end(body) + }, + }) + server.register({ + kind: 'exact', + path: '/archive', + handler: (_req, res) => { + res.writeHead(200, { 'content-type': 'application/gzip' }) + res.end(body) + }, + }) + server.register({ + kind: 'exact', + path: '/range', + handler: (_req, res) => { + res.writeHead(206, { 'content-type': 'text/plain', 'content-range': 'bytes 0-15/160' }) + res.end(body.slice(0, 16)) + }, + }) + + const compressed = await request(server.port, '/text', { headers: { 'accept-encoding': 'br, gzip, deflate' } }) + expect(compressed).toMatchObject({ status: 200, body: body.slice(0, 80) }) + expect(compressed.headers.get('content-encoding')).toBe('gzip') + expect(compressed.headers.get('content-length')).toBeNull() + expect(compressed.headers.get('vary')).toBe('Accept-Encoding') + const streamed = await request(server.port, '/stream', { headers: { 'accept-encoding': 'gzip' } }) + expect(streamed).toMatchObject({ body: body.slice(0, 80) }) + expect(streamed.headers.get('content-encoding')).toBe('gzip') + expect((await request(server.port, '/small', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + + const identity = await request(server.port, '/text', { + headers: { 'accept-encoding': 'gzip;q=0.5, identity;q=1' }, + }) + expect(identity.headers.get('content-encoding')).toBeNull() + expect(identity.headers.get('vary')).toBe('Accept-Encoding') + expect((await request(server.port, '/events', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + expect((await request(server.port, '/archive', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + expect((await request(server.port, '/range', { headers: { 'accept-encoding': 'gzip' } })) + .headers.get('content-encoding')).toBeNull() + }) + // Real-Loader composition resolves workspace packages through tsx at test // time; first resolution after the host/client program split is slow enough // to trip the default 5s budget on cold caches. @@ -200,6 +303,58 @@ describe('real Loader composition', () => { await expect(request(port, '/probe')).rejects.toThrow() }) + it('collects injection rows fresh per render and layers taps over the rendered rows', { timeout: 60_000 }, async () => { + const loaded = await loadComposition() + const server = loaded.webServer + let flag = 'dark' + loaded.on('webserver/index-inject', (table) => { + table.push( + { kind: 'script', placement: 'head', text: 'window.__Q__=1' }, + { kind: 'script-src', placement: 'head', src: '/plugins/a.js?rev="1"&x=' }, + { kind: 'script-preload', src: '/plugins/b.js?rev="2"&x=' }, + { kind: 'global', name: '__DSH_BOOT__', value: { rev: '' } }, + { kind: 'style', text: 'body{margin:0}' }, + { kind: 'html', placement: 'head', html: '' }, + { kind: 'script', placement: 'body', text: `window.__P__=${JSON.stringify(flag)}` }, + ) + }) + + const html = server.renderIndex('shell') + // Head rows land right after the opening head tag in table order; the body + // row lands right after the opening body tag. + const order = [ + '', + '', + '', + '', + 'globalThis["__DSH_BOOT__"] = {"rev":"\\u003c/script>\\u003cb>"}', + '', + '', + '', + '', + 'shell', + ].map(part => html.indexOf(part)) + expect(order).toEqual([...order].sort((a, b) => a - b)) + expect(order.every(at => at !== -1)).toBe(true) + + // Fresh collection per render: the listener reads live state at emit time. + flag = 'light' + expect(server.renderIndex('')).toContain('window.__P__="light"') + + // Raw taps still run, over the already-rendered rows. + const untap = server.tapIndex(h => h.replace('window.__Q__=1', 'window.__Q__=2')) + expect(server.renderIndex('')).toContain('window.__Q__=2') + untap() + + // Tag-less fragments: head rows prepend, body rows append, and the + // boot-readiness tail lands after the last body row. + expect(renderIndexInjections('

x
', [ + { kind: 'script', placement: 'head', text: 'H' }, + { kind: 'script', placement: 'body', text: 'B' }, + ])).toBe('
x
' + + '') + }) + it('fails the fiber when the port is already taken (fail-loud at activation)', { timeout: 60_000 }, async () => { const first = await loadComposition() const takenPort = first.webServer.port diff --git a/packages/identity/README.i18n.yaml b/packages/identity/README.i18n.yaml index 653704a83c..fe7a13ab33 100644 --- a/packages/identity/README.i18n.yaml +++ b/packages/identity/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/identity/README.md -README.md: ebbc7d937dfa793edf9a8617d86a1820e87866da -README.zh.md: 3d29fc3dee7599e366cb91c09d6a73c8acddab96 +README.md: 781b015eca49e6232f83b374f575fac62d400d5a +README.zh.md: ded57337c2ec26135ab11a82abafe05ed788da9d diff --git a/packages/identity/README.md b/packages/identity/README.md index ebbc7d937d..781b015eca 100644 --- a/packages/identity/README.md +++ b/packages/identity/README.md @@ -1,9 +1,37 @@ +--- +description: "The identity package group: anonymous, per-harness-home correlation ids shared by telemetry, feedback, and DeepSeek provider requests." +kind: "package-group" +--- + # identity/ — shared identity English | [中文](README.zh.md) -Identity values shared across product domains. These values do not represent an authenticated account. +## Summary -| Package | Role | ctx key | -|---|---|---| -| [`anonymous-user-id/`](anonymous-user-id/README.md) | Persists one anonymous Harness-home correlation id for telemetry, feedback, and DeepSeek requests | — | +The identity group provides one anonymous id per harness home that the installation's telemetry, feedback, and DeepSeek requests attach to their records, so everything leaving one home can be recognized as coming from the same installation without identifying the user. There is nothing to configure: the id appears automatically the first time one of those features runs and stays stable until its file is deleted. The group has one package; this page maps it, and the package README owns the details. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + + +## Packages + +| Package | Role | +|---|---| +| [`anonymous-user-id`](anonymous-user-id/README.md) | Gives every harness home one anonymous id that telemetry, feedback, and DeepSeek requests attach to their records, so records from one installation can be recognized without identifying the user | + + +## Related documentation + +- [Session telemetry subsystem](../../docs/subsystems/session-telemetry.md) — the telemetry feature that carries the id on exports. +- [dsh-llm-deepseek](../llm/llm-deepseek/README.md) — the DeepSeek provider that carries the id on requests. +- [dsh-command-feedback](../feedback/command-feedback/README.md) — the feedback command that names the anonymous installation in its acknowledgement. + + +## Dev Note + +None. diff --git a/packages/identity/README.zh.md b/packages/identity/README.zh.md index 3d29fc3dee..ded57337c2 100644 --- a/packages/identity/README.zh.md +++ b/packages/identity/README.zh.md @@ -1,9 +1,37 @@ +--- +description: "identity 包组:由遥测、反馈与 DeepSeek 提供方请求共享的匿名按 harness home 关联 id。" +kind: "package-group" +--- + # identity/ — 共享身份 [English](README.md) | 中文 -跨产品领域共享的身份值。这些值不表示经过身份验证的账户。 +## 概述 -| 包 | 职责 | ctx key | -|---|---|---| -| [`anonymous-user-id/`](anonymous-user-id/README.md) | 为遥测、反馈和 DeepSeek 请求持久化一个限定于 Harness home 的匿名关联 id | — | +identity 组为每个 harness home 提供一个匿名 id,该安装的遥测、反馈与 DeepSeek 请求会把它附加到各自的记录上,因此离开同一个 home 的所有内容都能被识别为来自同一套安装,而无需识别用户身份。无需配置任何东西:id 会在这些功能之一首次运行时自动出现,并在文件被删除前保持稳定。本组只有一个包;本页是组的映射,包 README 负责细节。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + + +## 包 + +| 包 | 职责 | +|---|---| +| [`anonymous-user-id`](anonymous-user-id/README.zh.md) | 让每个 harness home 拥有一个匿名 id,遥测、反馈与 DeepSeek 请求把它附加到记录上,使来自同一安装的记录无需识别用户即可被辨认 | + + +## 相关文档 + +- [会话遥测子系统](../../docs/subsystems/session-telemetry.zh.md)——在导出中携带该 id 的遥测功能。 +- [dsh-llm-deepseek](../llm/llm-deepseek/README.zh.md)——在请求中携带该 id 的 DeepSeek 提供方。 +- [dsh-command-feedback](../feedback/command-feedback/README.zh.md)——在确认文本中点名该匿名安装的反馈命令。 + + +## 开发备注 + +无。 diff --git a/packages/identity/anonymous-user-id/README.i18n.yaml b/packages/identity/anonymous-user-id/README.i18n.yaml index 3c2b22dca8..7f772adfca 100644 --- a/packages/identity/anonymous-user-id/README.i18n.yaml +++ b/packages/identity/anonymous-user-id/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/identity/anonymous-user-id/README.md -README.md: fb9d8f8046f41aed7c0bc8aa8cede46f9bb8c93b -README.zh.md: 738289fd6b132e3fd5b6cc5a89dbe8ca1ffb5e42 +README.md: 824daacd0afd42bf79d2434392f8a77781ba375f +README.zh.md: 6cf73ef718808bce1f649f98918dd1be0b0453b5 diff --git a/packages/identity/anonymous-user-id/README.md b/packages/identity/anonymous-user-id/README.md index fb9d8f8046..824daacd0a 100644 --- a/packages/identity/anonymous-user-id/README.md +++ b/packages/identity/anonymous-user-id/README.md @@ -1,22 +1,112 @@ +--- +description: "Anonymous per-harness-home identity for users and maintainers tracing how telemetry, feedback acknowledgement, and DeepSeek provider requests correlate records." +kind: "package-library" +--- + # @deepseek-ai/dsh-anonymous-user-id English | [中文](README.zh.md) -Shared anonymous identity for session telemetry, direct feedback acknowledgement, and DeepSeek provider requests. `getOrCreateAnonymousUserId()` returns a random UUID v4 scoped to one harness home, persisted as the bare line `$DSH_HOME/.anonymous-user-id` (`~/.dsh/.anonymous-user-id` when `DSH_HOME` is unset). The OpenTelemetry backend reports it as Resource `user.id`; `/feedback` includes the same value in its acknowledgement; and `dsh-llm-deepseek` sends it as `x-deepseek-harness-user-id`, allowing the receiving systems to correlate records without independently generated identities. +## Summary -The identity is never derived from the hostname, network address, git remote, or another identifying source. Deleting `.anonymous-user-id` resets the identity on the next process launch. Separate harness homes have separate identities. +Every harness home gets one anonymous id that telemetry, feedback, and DeepSeek requests attach to their records, so receiving systems can tell that records came from the same installation without learning who the user is. The id is a random UUID stored in `$DSH_HOME/.anonymous-user-id` (`~/.dsh` by default); it appears automatically the first time one of those features runs, stays stable across restarts, and is created fresh if you delete the file. Separate harness homes never share an id, and no machine or account detail goes into it. Use it whenever you want to correlate records from one installation without an account; it cannot join records across different homes. -## Storage contract +## Table of Contents -Reads and writes are synchronous because both boot-time telemetry construction and direct command execution need one API. The result is memoized per resolved file path for the process lifetime. A first writer uses exclusive creation and a concurrent loser adopts the persisted winner; a corrupt file is replaced. Persistence is best-effort, so an unwritable home still receives a process-local UUID rather than blocking telemetry or feedback. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -## Composition +----- -This package is a shared library, not a Cordis plugin. Consumers import `getOrCreateAnonymousUserId()` directly. Its invariant companion is intentionally empty because the package owns no event stream or public mutable relation that can be checked without creating the identity as a side effect. `DSH_TELEMETRY_DISABLED` stops telemetry export only; it does not suppress direct feedback acknowledgement or the DeepSeek provider header. + +## Use this package +When you want the records your installation sends out to be recognizable as coming from the same harness home — telemetry, feedback, and DeepSeek requests all carry one shared id — this package is what provides it. There is nothing to install or configure: the id appears automatically, and the shipped feedback, telemetry, and DeepSeek features already use it. Do not use it to identify a user or to join records across different homes; it is anonymous and home-scoped. + +### What the id does for you + +Three things your installation sends out carry the same id, so records line up across all of them: + +- **Session telemetry** — your telemetry exports carry the id as the `user.id` resource attribute, so a collector can group an installation's records. +- **Feedback** — each feedback acknowledgement names the anonymous installation that recorded it. +- **DeepSeek requests** — every provider request carries the `x-deepseek-harness-user-id` header, so usage can be attributed per installation. + +### Observing and resetting the id + +The id lives in `$DSH_HOME/.anonymous-user-id` (`~/.dsh` by default) as a plain UUID text file. Delete that file to get a fresh id at the next launch; the running process keeps its current id until it exits. Separate harness homes keep separate ids, and no machine or account detail ever goes into the value. + +### Using it in your own package + +When you build a feature that should share the installation's anonymous id, import the value once and reuse it — telemetry, feedback, and DeepSeek already use the same id, so your records line up with theirs: + +```ts +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' + +const userId = getOrCreateAnonymousUserId() // stable for the process lifetime +``` + +The value is stable for the process and matches what the built-in features use; it changes only when the file is deleted and a later launch mints a replacement. Even when the home directory cannot be written, the value still works for the current run, so records keep flowing. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains the design decisions behind the package and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package). + +### Design philosophy + +- **Random, never derived.** The id comes from `crypto.randomUUID()`; it is never derived from the hostname, network address, git remote, or any other identifying source, so anonymity is a property of the mint. +- **Synchronous and memoized.** One process touches the disk once: reads and writes are synchronous, and the result is memoized per resolved file path. +- **Best-effort persistence.** A write failure still returns a usable id for the run, so telemetry and feedback never block on an unwritable home. +- **Library, not plugin.** There is no Cordis plugin entry or config; the invariant companion installs an empty installer because the package owns no event stream or public mutable relation to compare without creating the id as a side effect. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Library entry: `getOrCreateAnonymousUserId`, file persistence, per-path memoization | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion with an empty installer (no runtime invariant; the only relation is private and side-effecting) | +| [`tests/anonymous-user-id.spec.ts`](tests/anonymous-user-id.spec.ts) | Exercised behavior: mint, persistence, corruption, concurrency, memoization | +| [`tests/invariant.spec.ts`](tests/invariant.spec.ts) | Companion registration through the invariants service | + +### The API + +The package exposes one function that returns the installation's anonymous id, minting and persisting it on first use; the exact signature, options, and defaults live in `src/index.ts`. + +### Storage contract + +The file is a bare UUID line named by `ANONYMOUS_USER_ID_FILE_NAME`, validated against a UUID pattern on read. A first writer uses exclusive creation (`wx`); a concurrent loser rereads and adopts the winner's value. A corrupt or unreadable file falls through to mint-and-overwrite. Memoization is keyed by resolved file path, so distinct homes never share an id. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the identity group map to the home-path resolution this package builds on and the features that use the id. + +- [identity group map](../README.md) — the sibling packages and group scope. +- [dsh-home-paths](../../util/home-paths/README.md) — owns `$DSH_HOME` and `~/.dsh` resolution. +- [dsh-session-telemetry-otel](../../session/session-telemetry-otel/README.md) — reports the id as the OTel Resource `user.id`. +- [dsh-command-feedback](../../feedback/command-feedback/README.md) — embeds the id in the feedback acknowledgement. +- [dsh-llm-deepseek](../../llm/llm-deepseek/README.md) — sends `x-deepseek-harness-user-id` on provider requests. +- [Session telemetry subsystem](../../../docs/subsystems/session-telemetry.md) — the telemetry seam and its backend contract. + +----- + + ## Model Experience -None, as the identifier reaches DeepSeek only as model-hidden HTTP transport metadata and never enters the request body, prompt, or model-visible content. +None, as the shared identifier reaches DeepSeek only as model-hidden HTTP metadata and registers nothing model-facing. #### KV Cache effect @@ -24,7 +114,31 @@ None; the transport header changes neither tokens nor the model-visible prefix. ## Known Limitations and Deferred Work -- **No recovery after deletion** — loss mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity. + + + +These limits describe when the id is a poor fit or needs special attention. They are current package constraints, not a general comparison of anonymity approaches or a task backlog. + +- **No recovery after deletion** — losing the file mints a new anonymous identity by design; recovery would require stable derivation material that weakens anonymity. - **Best-effort concurrency** — a reader landing in the narrow interval between a concurrent process's exclusive create and completed write can use a different in-memory UUID for that run; later launches converge on the persisted value. - **No cross-home identity** — different `$DSH_HOME` values cannot be correlated. - **Configured DeepSeek gateways receive the id** — `dsh-llm-deepseek` sends the stable header to its resolved `baseURL`, including deployment overrides, independently of telemetry sharing mode. +- **Deleting the file does not reset the current process** — memoization keeps the run's id until the next launch. + + +### Dev Note + +
+Working context for maintainers — click to expand + +This Dev Note is working context for maintainers: open questions and directions that are not decided. It is explicitly non-authoritative — shipped behavior, limits, and accepted rationale live in the sections above and the package code, and conclusions migrate there once they stabilize. + +#### Open: file-format evolution + +The persistence contract is a bare UUID line with no version marker. Adding a second value beside the id, or wrapping the line in a container, has no migration story for existing files; a versioned line format is one way to make such a change safe. + +#### Open: invariant coverage + +The invariant companion registers an empty installer because no relation can be checked without creating the id as a side effect. A future invariant could compare a re-read of the persisted file against the memoized id at a safe observation point. + +
diff --git a/packages/identity/anonymous-user-id/README.zh.md b/packages/identity/anonymous-user-id/README.zh.md index 738289fd6b..6cf73ef718 100644 --- a/packages/identity/anonymous-user-id/README.zh.md +++ b/packages/identity/anonymous-user-id/README.zh.md @@ -1,30 +1,144 @@ +--- +description: "面向用户与维护者的匿名按 harness home 身份说明,用于追踪遥测、反馈确认与 DeepSeek 提供方请求如何关联记录。" +kind: "package-library" +--- + # @deepseek-ai/dsh-anonymous-user-id [English](README.md) | 中文 -会话遥测、直接反馈确认与 DeepSeek 提供方请求共用的匿名身份。`getOrCreateAnonymousUserId()` 返回一个限定于单个 harness home 的随机 UUID v4,并以裸行形式持久化到 `$DSH_HOME/.anonymous-user-id`(未设置 `DSH_HOME` 时为 `~/.dsh/.anonymous-user-id`)。OpenTelemetry 后端将其作为 Resource 的 `user.id` 上报;`/feedback` 在确认文本中包含同一个值;`dsh-llm-deepseek` 则通过 `x-deepseek-harness-user-id` 发送该值,使接收系统无需独立生成身份即可关联记录。 +## 概述 -该身份绝不从 hostname、网络地址、git remote 或其他可用于识别身份的来源派生。删除 `.anonymous-user-id` 后,下次启动进程时会重置身份。不同 harness home 拥有不同身份。 +每个 harness home 都会获得一个匿名 id,遥测、反馈与 DeepSeek 请求会把它附加到各自的记录上,让接收系统无需了解用户身份即可判断记录来自同一套安装。该 id 是存储在 `$DSH_HOME/.anonymous-user-id`(默认 `~/.dsh`)中的随机 UUID;它会在这些功能之一首次运行时自动出现,跨重启保持稳定,删除文件后会重新生成。不同 harness home 永远不会共享同一个 id,其中也不包含任何机器或账户信息。当你希望关联来自同一套安装、且不依赖账户的记录时使用它;它无法关联不同 home 之间的记录。 -## 存储约定 +## 目录 -读写采用同步方式,因为启动时构造遥测和直接执行命令都需要使用同一个 API。结果在进程生命周期内按解析后的文件路径缓存。首个写入方采用独占创建;并发竞争中失败的一方会采用已持久化的胜出值。损坏的文件会被替换。持久化采用 best-effort,因此即使 home 不可写,系统仍会返回进程本地 UUID,而不会阻塞遥测或反馈。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -## 组合 +----- -本包是共享库,并非 Cordis 插件。消费方直接导入 `getOrCreateAnonymousUserId()`。其不变式伴生插件刻意留空,因为本包既不拥有事件流,也不拥有任何可以在不触发创建身份这一副作用的情况下检查的公开可变关系。`DSH_TELEMETRY_DISABLED` 只会停止遥测导出,不会禁止直接反馈确认或 DeepSeek 提供方标头。 + +## 使用本包 +当你希望本机安装外发的记录能被识别为来自同一个 harness home——遥测、反馈与 DeepSeek 请求都携带同一个共享 id——本包就是提供它的地方。无需安装或配置任何东西:id 会自动出现,已随附的反馈、遥测与 DeepSeek 功能已经在使用它。不要用它来识别用户,也不要用它关联不同 home 之间的记录;它是匿名的且限定于单个 home。 + +### 该 id 能为你做什么 + +你的安装外发的三类内容携带同一个 id,因此记录在它们之间可以相互对应: + +- **会话遥测**——你的遥测导出会以 `user.id` Resource 属性携带该 id,采集器因此可以按安装分组记录。 +- **反馈**——每条反馈确认都会指名记录该反馈的匿名安装。 +- **DeepSeek 请求**——每次提供方请求都会携带 `x-deepseek-harness-user-id` 标头,因此可以按安装归因用量。 + +### 查看与重置 id + +该 id 存放在 `$DSH_HOME/.anonymous-user-id`(默认 `~/.dsh`)中,是一个纯 UUID 文本文件。删除该文件即可在下次启动时获得全新 id;正在运行的进程在退出前会一直保留当前 id。不同 harness home 各自保留独立 id,值中永远不会包含任何机器或账户信息。 + +### 在自己的包中使用 + +当你构建的功能需要共享该安装的匿名 id 时,导入该值并复用一次即可——遥测、反馈与 DeepSeek 已经在使用同一个 id,因此你的记录能与它们相互对应: + +```ts +import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-anonymous-user-id' + +const userId = getOrCreateAnonymousUserId() // stable for the process lifetime +``` + +该值在进程内保持稳定,并与内置功能使用的值一致;只有当文件被删除、后续启动生成替代值时才会改变。即使 home 目录不可写,该值在本次运行中依然可用,记录因此不会中断。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释本包背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 + +### 设计理念 + +- **随机生成,绝不派生。** id 来自 `crypto.randomUUID()`;绝不从 hostname、网络地址、git remote 或任何其他可识别来源派生,因此匿名性是生成过程的属性。 +- **同步且记忆化。** 一个进程只触碰一次磁盘:读写都是同步的,结果按解析后的文件路径记忆化。 +- **Best-effort 持久化。** 写入失败仍会为本次运行返回可用 id,遥测与反馈因此不会因 home 不可写而阻塞。 +- **库而非插件。** 没有 Cordis 插件入口或配置;不变式伴生插件安装空安装器,因为本包不拥有任何事件流或公开可变关系,无法在不产生创建 id 这一副作用的情况下比较。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 库入口:`getOrCreateAnonymousUserId`、文件持久化、按路径记忆化 | +| [`src/invariant.ts`](src/invariant.ts) | 带空安装器的不变式伴生插件(无运行时不变式;唯一的关系是私有的且带副作用) | +| [`tests/anonymous-user-id.spec.ts`](tests/anonymous-user-id.spec.ts) | 已演练行为:生成、持久化、损坏、并发、记忆化 | +| [`tests/invariant.spec.ts`](tests/invariant.spec.ts) | 通过 invariants 服务注册伴生插件 | + +### API + +本包暴露一个函数,返回该安装的匿名 id,并在首次使用时生成并持久化;确切的签名、选项与默认值见 `src/index.ts`。 + +### 存储约定 + +文件是名为 `ANONYMOUS_USER_ID_FILE_NAME` 的裸 UUID 行,读取时按 UUID 模式校验。首个写入方使用独占创建(`wx`);并发落败方重新读取并采用胜出方的值。损坏或不可读的文件会落入生成并覆盖的路径。记忆化按解析后的文件路径为键,因此不同 home 永远不会共享 id。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从 identity 组映射逐步进入本包所依赖的 home 路径解析,以及使用该 id 的功能。 + +- [identity 组映射](../README.zh.md)——兄弟包与组范围。 +- [dsh-home-paths](../../util/home-paths/README.zh.md)——负责 `$DSH_HOME` 与 `~/.dsh` 的解析。 +- [dsh-session-telemetry-otel](../../session/session-telemetry-otel/README.zh.md)——将该 id 作为 OTel Resource `user.id` 上报。 +- [dsh-command-feedback](../../feedback/command-feedback/README.zh.md)——将 id 嵌入反馈确认。 +- [dsh-llm-deepseek](../../llm/llm-deepseek/README.zh.md)——在提供方请求中发送 `x-deepseek-harness-user-id`。 +- [会话遥测子系统](../../../docs/subsystems/session-telemetry.zh.md)——遥测 seam 及其后端约定。 + +----- + + ## 模型体验 -无,因为该标识符只会作为模型不可见的 HTTP 传输元数据发送给 DeepSeek,绝不会进入请求正文、提示词或模型可见内容。 +无,因为该共享标识符只会作为模型不可见的 HTTP 元数据发送给 DeepSeek,且不注册任何面向模型的内容。 #### KV Cache 影响 无;该传输标头既不会改变 token,也不会改变模型可见前缀。 -## 已知限制与暂缓工作 +## 已知限制与延期工作 -- **删除后无法恢复**:身份丢失后会按设计生成新的匿名身份;若要恢复身份,就需要稳定的派生材料,这会削弱匿名性。 -- **Best-effort 并发**:如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。 -- **没有跨 home 身份**:不同 `$DSH_HOME` 值之间无法关联。 -- **已配置的 DeepSeek gateway 会收到该 id**:`dsh-llm-deepseek` 会把稳定标头发送至解析后的 `baseURL`(包括部署覆盖),且不受遥测共享模式影响。 + + + +这些限制说明该 id 何时不合适或需要特别注意。它们是当前包约束,不是匿名性方案的通用对比,也不是任务积压。 + +- **删除后无法恢复**——文件丢失后会按设计生成新的匿名身份;恢复需要稳定的派生材料,这会削弱匿名性。 +- **Best-effort 并发**——如果读取方恰好落在并发进程完成独占创建但尚未写完的狭窄时间窗内,本次运行可能使用不同的内存 UUID;后续启动会收敛到已持久化的值。 +- **没有跨 home 身份**——不同 `$DSH_HOME` 值之间无法关联。 +- **已配置的 DeepSeek gateway 会收到该 id**——`dsh-llm-deepseek` 会把稳定标头发送至解析后的 `baseURL`(包括部署覆盖),且不受遥测共享模式影响。 +- **删除文件不会重置当前进程**——记忆化会让本次运行的 id 一直保留到下次启动。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +本开发备注是维护者的工作上下文:开放问题与尚未决定的探索方向。它明确不具权威性——已交付的行为、限制与既定理由以上文和包代码为准,结论一旦稳定就迁移到对应归属。 + +#### 开放:文件格式演进 + +持久化约定是没有任何版本标记的裸 UUID 行。在 id 旁边增加第二个值,或用容器包裹该行,对现有文件都没有迁移方案;带版本的行格式是让此类变更安全的一种方式。 + +#### 开放:不变式覆盖 + +不变式伴生插件注册空安装器,因为任何关系都无法在不产生创建 id 这一副作用的情况下检查。未来的不变式可以在安全的观测点上,把重新读取的持久化文件与记忆化的 id 进行比较。 + +
diff --git a/packages/identity/anonymous-user-id/package.json b/packages/identity/anonymous-user-id/package.json index f967e48d2b..35019e6c7b 100644 --- a/packages/identity/anonymous-user-id/package.json +++ b/packages/identity/anonymous-user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-anonymous-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/README.i18n.yaml b/packages/interaction/README.i18n.yaml index 116db6b260..249d07903a 100644 --- a/packages/interaction/README.i18n.yaml +++ b/packages/interaction/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/interaction/README.md -README.md: a7842e40aa708ee9ec159dd51b8a94a6b2b18539 -README.zh.md: ec329365331efd2769078078d1da4a8e1b3b57e0 +README.md: 81c7feb4b8bad2019e20eb39881fc89e520764ea +README.zh.md: 5932c4b399595289eab9bbcf5d2b1bc0318b996c diff --git a/packages/interaction/README.md b/packages/interaction/README.md index a7842e40aa..81c7feb4b8 100644 --- a/packages/interaction/README.md +++ b/packages/interaction/README.md @@ -1,17 +1,56 @@ +--- +description: "Package map for the human-collaboration capability family: slash commands, one-shot approvals, permission presets, and the question/answer seam that lets a running agent pause for a human decision." +kind: "package-group" +--- + # interaction/ — the human-collaboration plane English | [中文](README.zh.md) -The services and plugins through which a human collaborates with a running agent — questions, approvals, permission presets, commands. These are **product** packages: real interfaces a person drives. +## Summary + +The `interaction/` group is where a human collaborates with a running agent. It provides the slash-command plane users type into, the one-shot approval decisions behind sensitive actions, named permission presets that bundle sandbox mode with an approval policy, and the question/answer service an agent pauses on when it needs a human decision. All five packages are product packages — the real interfaces a person drives — and the product `dsh` CLI composes them directly. Interactive applications drive the command, approval, and question interfaces directly, while automation uses the ACP transport. The subsystem references own the exhaustive contracts; this map points at each package and its neighbors. + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages + +Each package README and its subsystem reference own the exhaustive contracts. | Package | Role | ctx key | |---|---|---| -| [`commands/`](commands/README.md) | Registers and dispatches human commands for interactive adapters. | `ctx.commands` | -| [`user-approval/`](user-approval/README.md) | Coordinates one-shot approval decisions. | `ctx.approval` | -| [`permission/`](permission-presets/README.md) | Presents and persists user-facing permission presets. | `ctx.permissionPresets` | -| [`user-questions/`](user-questions/README.md) | Defines the provider-neutral human question/answer seam. | `ctx.userQuestions` | -| [`tool-ask-user/`](tool-ask-user/README.md) | Exposes human questions to the model. | (registers on `ctx.tools`) | +| [`commands/`](commands/README.md) | Lets users type slash commands that run directly against an agent without a model round trip | `ctx.commands` | +| [`user-approval/`](user-approval/README.md) | Asks composed answerers for one-shot allow/reject decisions and fails closed without one | `ctx.approval` | +| [`permission-presets/`](permission-presets/README.md) | Bundles sandbox mode with an approval policy into one user-facing Permissions selector | `ctx.permissionPresets` | +| [`user-questions/`](user-questions/README.md) | Defines the validated question schema and scoped answerer waterfall an agent pauses on | `ctx.userQuestions` | +| [`tool-ask-user/`](tool-ask-user/README.md) | Exposes the `ask_user_question` tool so the model can ask the human for a decision | registers on `ctx.tools` | -These packages integrate through existing agent and session contracts rather than changing the loop. Interactive applications provide the concrete command, approval, and question adapters; automation uses [`acp/`](../acp/README.md), and runnable demo bundles live under [`examples/`](../examples/README.md). The product [`dsh`](../../apps/cli/README.md) CLI composes these packages directly. +----- -The subsystem references: [approval.md](../../docs/subsystems/approval.md), [permission-presets.md](../../docs/subsystems/permission-presets.md), [user-questions.md](../../docs/subsystems/user-questions.md), and [commands.md](../../docs/subsystems/commands.md). The automation-only ACP transport is [`acp/`](../acp/README.md), the SDK's JSON-RPC server half is [`sdk/server`](../sdk/README.md), and the shared bin boot glue is [`boot/`](../boot/README.md). + +## Related documentation + +Start with the subsystem references for the shared vocabularies, then the neighboring automation and composition surfaces. + +- [Commands subsystem](../../docs/subsystems/commands.md) — command registry semantics and the `ctx.commands` cordis surface. +- [Approval subsystem](../../docs/subsystems/approval.md) — request/outcome vocabulary, the answerer waterfall, and per-session policy. +- [Permission presets subsystem](../../docs/subsystems/permission-presets.md) — the preset table and the knob write-through. +- [User interaction subsystem](../../docs/subsystems/user-questions.md) — question vocabulary, answerer waterfall, and presentation intent. +- [ACP group](../acp/README.md) — the automation-only transport that answers approval requests for its own agents. + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/interaction/README.zh.md b/packages/interaction/README.zh.md index ec32936533..5932c4b399 100644 --- a/packages/interaction/README.zh.md +++ b/packages/interaction/README.zh.md @@ -1,17 +1,56 @@ +--- +description: "人机协作能力族的包映射:斜杠命令、一次性审批、权限预设,以及让运行中的 agent 暂停等待人类决定的问答 seam。" +kind: "package-group" +--- + # interaction/:人机协作平面 [English](README.md) | 中文 -人与运行中的 agent(智能体)协作所经由的服务与插件——提问、审批、权限预设、命令。这些是**产品**包:由用户直接操作的真实接口。 +## 概述 -| 包 | 职责 | ctx 键 | +`interaction/` 组是人机协作的场所。它提供用户输入所用的斜杠命令平面、敏感操作背后的一次性审批决定、把沙箱模式与审批策略捆绑为具名预设的权限预设,以及 agent 需要人类决定时暂停等待的问答服务。五个包都是产品包——由用户直接操作的真实接口——产品 `dsh` CLI 直接组合它们。交互式应用直接驱动命令、审批与提问接口,自动化则改用 ACP 传输。子系统参考拥有穷尽式约定;本映射指向每个包及其相邻包。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 + +每个包的 README 与对应子系统参考拥有穷尽式约定。 + +| 包 | 角色 | ctx 键 | |---|---|---| -| [`commands/`](commands/README.md) | 为交互式适配器注册并分派用户命令。 | `ctx.commands` | -| [`user-approval/`](user-approval/README.md) | 协调一次性审批决策。 | `ctx.approval` | -| [`permission/`](permission-presets/README.md) | 呈现并持久化面向用户的权限预设。 | `ctx.permissionPresets` | -| [`user-questions/`](user-questions/README.md) | 定义与提供方无关的用户问答 seam。 | `ctx.userQuestions` | -| [`tool-ask-user/`](tool-ask-user/README.md) | 向模型提供用户问题。 | (注册到 `ctx.tools`) | +| [`commands/`](commands/README.zh.md) | 让用户输入斜杠命令,直接针对 agent 执行,无需模型往返 | `ctx.commands` | +| [`user-approval/`](user-approval/README.zh.md) | 向已组合的应答者征求一次性允许/拒绝决定,缺失时以拒绝方式关闭 | `ctx.approval` | +| [`permission-presets/`](permission-presets/README.zh.md) | 把沙箱模式与审批策略捆绑为一个面向用户的权限选择器 | `ctx.permissionPresets` | +| [`user-questions/`](user-questions/README.zh.md) | 定义经过校验的问题 schema 与作用域 answerer waterfall,agent 可暂停等待 | `ctx.userQuestions` | +| [`tool-ask-user/`](tool-ask-user/README.zh.md) | 暴露 `ask_user_question` 工具,让模型可以向用户提问 | 注册到 `ctx.tools` | -这些包通过现有的 agent 和会话约定集成,而不改变循环。交互式应用提供具体的命令、审批和提问适配器;自动化使用 [`acp/`](../acp/README.md),可运行的演示组合包位于 [`examples/`](../examples/README.md)。产品 [`dsh`](../../apps/cli/README.md) CLI(命令行界面)直接组合这些包。 +----- -子系统参考:[approval.md](../../docs/subsystems/approval.md)、[permission-presets.md](../../docs/subsystems/permission-presets.md)、[user-questions.md](../../docs/subsystems/user-questions.md)与 [commands.md](../../docs/subsystems/commands.md)。仅自动化的 ACP 传输是 [`acp/`](../acp/README.md),SDK 的 JSON-RPC 服务器端是 [`sdk/server`](../sdk/README.md),共享 bin 启动胶水是 [`boot/`](../boot/README.md)。 + +## 相关文档 + +先从子系统参考了解共享词汇,再看相邻的自动化与组合面。 + +- [命令子系统](../../docs/subsystems/commands.zh.md)——命令注册表语义与 `ctx.commands` 的 cordis 接口面。 +- [审批子系统](../../docs/subsystems/approval.zh.md)——请求/结果词汇、应答者瀑布与按会话策略。 +- [权限预设子系统](../../docs/subsystems/permission-presets.zh.md)——预设表与旋钮写穿。 +- [用户交互子系统](../../docs/subsystems/user-questions.zh.md)——问题词汇、answerer waterfall 与呈现意图。 +- [ACP 组](../acp/README.zh.md)——仅自动化的传输,为其自有 agent 回答审批请求。 + + +## 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/interaction/commands/README.i18n.yaml b/packages/interaction/commands/README.i18n.yaml index fa8ce400f3..c5737c521f 100644 --- a/packages/interaction/commands/README.i18n.yaml +++ b/packages/interaction/commands/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/interaction/commands/README.md -README.md: 4a4cb2a70b56ba1a18e9f4719541a50a9683510c -README.zh.md: f89ccd1a5cc9189b2810026481d4c57855481111 +README.md: 4f7a1f4475bf7edeb8e3bae6eec8b8a04b411c3d +README.zh.md: af9a23adf6a88a0b046760ece6e3cf1cdaac1fd6 diff --git a/packages/interaction/commands/README.md b/packages/interaction/commands/README.md index 4a4cb2a70b..4f7a1f4475 100644 --- a/packages/interaction/commands/README.md +++ b/packages/interaction/commands/README.md @@ -1,23 +1,118 @@ +--- +description: "Human slash-command registry for interactive UIs: plugin-owned commands that run directly against an agent without creating a model message, for users and maintainers composing or extending command surfaces." +kind: "package-reference" +--- + # @deepseek-ai/dsh-commands English | [中文](README.zh.md) -Plugin-owned human-command registry consumed by interactive UI adapters. The [plugin command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) owns the boundary and dispatch contract. +## Summary -## Service contract +`dsh-commands` lets a user type `/command [input]` in an interactive Harness UI and run it directly against the receiving agent without creating a model message. Plugins register commands with a name, description, optional input hint and image-acceptance flag, and an abortable handler; interactive adapters discover and dispatch them per agent. A command-producing plugin mounted under an agent's context can register an exact agent-scoped command that shadows the global one of the same name. Each command run is recorded in the session log, and its result is rendered by the adapter, never entering model history. Slash commands ship with the `dsh` CLI and the Web client. -`ctx.commands.register(definition)` registers one lowercase command name, description, optional unstructured-input descriptor (`hint` plus an `images` flag declaring whether composer image attachments may accompany an invocation), optional `recordInput` policy, and abortable handler. `recordInput` defaults to true; a command whose authoritative domain event owns the payload sets it to false so `command/run` omits `args` instead of duplicating the input. A registered command is available to every composed command adapter; a plugin that is incompatible with a deployment does not register there. A plain-context registration is global. A command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection and creates an exact agent-scoped definition; it shadows a global definition with the same name. This child-injection shape preserves the agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration. Every disposer is the exact Cordis effect disposer, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the registry mutation or starve later observers. +## Table of Contents -`list(agent)` returns immutable, name-sorted descriptors after scoped shadowing (the descriptor carries `input.images` so composers can refuse image submissions to non-declaring commands before dispatch). `find(agent, name)` returns the corresponding definition. `execute(agent, line, images, signal)` uses `parseCommand()` and runs only a known command, returning the settled `CommandExecution` (the normalized result plus the lifecycle pairing `commandId`) or `undefined` for invalid syntax or unknown names. `images` carries the submission's base64-encoded composer images (`EncodedImageAttachment` from `@deepseek-ai/dsh-attachment/types`); the executor enforces the declaration — images sent to a non-declaring command, an absent `attachments` store, or an exceeded batch limit each settle as an error result before the handler runs, and a rejected batch publishes no durable object. An admitted batch is committed through `admitEncodedImages` and handed to the handler as frozen ordered `ImageBlock`s on `invocation.attachments`; the handler owns their model-visible use and returns an error when its grammar cannot use them, so the dispatching composer keeps the originals. A resolved command's lifecycle is logged on the receiving agent's session as the log-only pair `command/run` (before the handler, with a minted `commandId`, the parser's structured name, the issuing `CommandSource`, and `args` unless `recordInput` is false) and `command/done` (at settlement, with the outcome kind and verbatim text; a successful result may also name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`). Admission misses log nothing. Both are direct standalone appends on the receiving agent's session: no turn wraps them, and persistence drains them through ordinary checkpoints and teardown. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -`parseCommand()` recognizes a slash at byte zero, a lowercase name containing letters, digits, `_`, or `-`, and either end-of-input or whitespace. It returns every byte after the name as `rawInput`, including separator whitespace; consumers own their command-specific grammar and may normalize only what that grammar permits. +----- -Handlers return `success` or `error` plus optional UI text. A successful handler may also return `sourceEventSeq` when an earlier domain event owns a richer presentation; the lifecycle invariant requires that reference to be a prior non-command event in the same session. Results are rendered directly by the adapter and never enter model history. The registry never submits `rawInput` to the agent implicitly; a command producer may explicitly schedule model-visible work through the receiving `Agent`, in which case that producer owns the resulting message contract. The registry races handler completion against the supplied abort signal, but an uncooperative handler may continue its own external side effects after the caller stops awaiting it. + +## Use this package -## Composition +Compose this service when an interactive UI should let users drive agent-side behavior with slash commands instead of model prompts. UI-less demo spines and ACP automation provide no command adapter and do not need it. -The shipped `dsh` base mounts this service and the Web client dispatches through it. UI-less demo spines and ACP automation do not provide a command adapter. Custom interactive compositions and command producers mount `@deepseek-ai/dsh-commands` explicitly. +### Registering a command +A plugin registers a command with `ctx.commands.register()`: a lowercase name, a description shown in discovery, an optional `input` hint, and a handler that runs against the receiving agent. + +```text +ctx.commands.register({ + name: 'plan', + description: 'Enter plan mode', + input: { hint: '' }, + handler: ({ agent, rawInput }) => { + // Runs directly against the agent; no model message is created. + return { kind: 'success', text: 'plan mode selected' } + }, +}) +``` + +The handler returns `success` or `error` plus optional UI text that the adapter renders. `recordInput` defaults to true; a command whose own authoritative domain event already carries the payload sets it to false so the session log does not duplicate the input. Registering the same name twice in one scope throws. + +### Command syntax + +A command line starts with a slash at byte zero, a lowercase name containing letters, digits, `_` or `-`, and then either end-of-input or whitespace. Everything after the name — including separator whitespace — is the command's `rawInput`, and the command owns its own grammar for it. Lines that are not syntactically a command, or that name an unknown command, are rejected by the adapter instead of becoming a model prompt. + +### Agent-scoped commands + +A plain registration is global. A command-producing plugin mounted beneath an agent's own context declares its `commands` injection and registers an exact agent-scoped command, which shadows the global definition of the same name for that agent only. + +### Image attachments + +A command may declare `input.images` to accept composer image attachments. The executor enforces the declaration: images sent to a non-declaring command, an absent attachment store, or an over-limit batch each settle as an error before the handler runs. Admitted images reach the handler as frozen ordered `ImageBlock`s on `invocation.attachments`, and the handler owns their model-visible use — the registry never schedules them itself. + +### Dispatching from an adapter + +An interactive adapter calls `execute(agent, line, images, signal)` with the exact receiving agent, the full command line, and the submission's images. It returns the settled `CommandExecution` — the normalized result plus its lifecycle `commandId` — or `undefined` for invalid syntax or an unknown name. `list(agent)` and `find(agent, name)` serve discovery after agent-scoped shadowing. + +### Cancellation + +The caller's abort signal stops the registry from awaiting a handler; a handler that ignores the signal may continue its own external side effects after the caller stops waiting. A cancelled or thrown handler settles as a `command/done` error in the log. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The observable behavior is covered in [Use this package](#use-this-package); this section explains how the registry is built and where its contracts live. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `CommandRuntime` service: registration, scoping, dispatch, lifecycle events | +| [`src/types.ts`](src/types.ts) | Command definition, descriptor, execution, and result types | +| [`src/brand.ts`](src/brand.ts) | `CommandId` brand for lifecycle pairing ids | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion pairing `command/run` with `command/done` per session log | + +### Lifecycle events + +`execute()` mints a `commandId`, appends `command/run` before the handler runs, and appends `command/done` at settlement with the outcome kind and verbatim text; the exact payload fields live in [`src/index.ts`](src/index.ts). A successful result may name an earlier non-command authoritative domain event through `sourceEventSeq`; a thrown or aborted handler settles as `kind: 'error'`. Both events are direct standalone log-only appends: no turn wraps them, and persistence drains them at ordinary checkpoints and teardown. Admission misses (invalid syntax or unknown name) log nothing. + +### Scoping + +Registrations live in global and agent-scoped layers merged per agent via `ScopedLayers`. The child-injection shape — a command-producing plugin mounted beneath `agent.ctx` declares its own `commands` injection — preserves agent scope without making the core agent loop depend on a UI service. Duplicate names within one layer fail during registration, and registration or removal notifies every `commands/change` observer so live adapters can refresh discovery; observer failures are logged and cannot veto the mutation or starve later observers. + +### Image admission + +Image enforcement happens in the executor, not the composer: an admitted batch is committed through `admitEncodedImages` against the `attachments` store, a rejected batch publishes no durable object, and cancellation is honored before the handler runs so a retrying caller never duplicates state. Handlers that cannot use the images return an error, so the dispatching composer keeps the originals. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the shared command vocabulary to the design evidence and adjacent surfaces. + +- [Commands subsystem reference](../../../docs/subsystems/commands.md) — registry semantics, input metadata, and the `ctx.commands` cordis surface. +- [Command registration Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md) — the boundary and dispatch contract behind this service. +- [Interaction group map](../README.md) — adjacent approval, permission, and question packages. +- [Plan mode package](../../plan/plan-mode/README.md) — a shipped command producer that drives model-visible work. + +----- + + ## Model Experience ### Direct human commands @@ -36,5 +131,20 @@ Registry metadata, command input, and direct output never enter a model request ## Known Limitations and Deferred Work + + + +These limits define what the registry does not offer. They are current package constraints, not a UI backlog. + - **Only unstructured text input** — forms, completion schemas, and typed arguments remain command-owned parsing concerns. - **Cooperative side-effect cancellation** — dispatch stops awaiting on abort; handlers must honor the signal to stop work that has already escaped into external systems. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/interaction/commands/README.zh.md b/packages/interaction/commands/README.zh.md index f89ccd1a5c..af9a23adf6 100644 --- a/packages/interaction/commands/README.zh.md +++ b/packages/interaction/commands/README.zh.md @@ -1,30 +1,125 @@ +--- +description: "面向交互式 UI 的人类斜杠命令注册表:插件拥有的命令直接针对 agent 执行,不产生模型消息;供组合或扩展命令面的用户与维护者阅读。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-commands [English](README.md) | 中文 -由插件负责、供交互式 UI 适配器使用的面向用户命令注册表。[插件命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.md)定义了其边界与分发约定。 +## 概述 -## 服务约定 +`dsh-commands` 让用户能在交互式 Harness UI 中输入 `/command [input]`,并直接针对接收命令的 agent(智能体)执行,不产生模型消息。插件注册命令时提供名称、描述、可选的输入提示与图片接受标志,以及可中止的处理器;交互式适配器按 agent 发现并分派这些命令。挂载在 agent 上下文之下的命令生产插件可以注册精确限定到该 agent 的命令,它会遮蔽同名的全局定义。每次命令执行都会记录在接收 agent 的会话日志中,结果由适配器渲染,绝不进入模型历史。斜杠命令随 `dsh` CLI 与 Web 客户端一起提供。 -`ctx.commands.register(definition)` 注册一个小写命令名称、描述、可选的非结构化输入描述符(`hint`,以及声明调用是否可携带 composer 图片附件的 `images` 标志)、可选的 `recordInput` 策略,以及可中止的处理器。`recordInput` 默认为 true;若载荷由命令的权威领域事件持有,该命令会将 `recordInput` 设为 false,让 `command/run` 省略 `args`,避免重复记录输入。每个已注册命令都可供所有已组合的命令适配器使用;与某项部署不兼容的插件不会在此注册。普通上下文中的注册全局生效。在 `agent.ctx` 下挂载的命令生产插件会声明自身的 `commands` 注入,并创建精确限定到该 agent(智能体)的定义;该定义会遮蔽同名的全局定义。这种子级注入形态保留了 agent 作用域,同时不会让核心 agent loop(智能体循环)依赖 UI 服务。同一层中的名称重复会在注册时失败。每个 disposer 都是 Cordis effect 返回的确切 disposer;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 +## 目录 -`list(agent)` 在应用作用域遮蔽后,返回按名称排序的不可变描述符(描述符携带 `input.images`,使 composer 能在分发前就拒绝把图片提交给未声明的命令)。`find(agent, name)` 返回相应定义。`execute(agent, line, images, signal)` 使用 `parseCommand()`,且只运行已知命令,返回已结算的 `CommandExecution`(规范化结果加生命周期配对 `commandId`);语法无效或名称未知时返回 `undefined`。`images` 携带本次提交的 base64 编码 composer 图片(来自 `@deepseek-ai/dsh-attachment/types` 的 `EncodedImageAttachment`);执行器负责声明的强制执行:把图片发给未声明的命令、`attachments` 存储缺失、或批量超出限制,都会在处理器运行前以错误结果结算,被拒绝的批量不会发布任何持久化对象。通过准入的批量经 `admitEncodedImages` 提交,并以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器;处理器负责它们的模型可见用途,当其语法无法使用这些图片时返回错误,使分发方 composer 保留原件。已解析命令的生命周期会以 log-only 事件对的形式记录在接收 agent 的会话日志中:`command/run`(进入处理器前记录,携带新生成的 `commandId`、解析器的结构化名称、发起方 `CommandSource`,以及 `args`(`recordInput` 为 false 时省略))与 `command/done`(结算时记录,携带结果类型与原样文本;成功结果还可通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算)。未通过准入的输入不记录任何事件。两者都直接独立追加到接收 agent 的会话中:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -`parseCommand()` 识别位于第 0 字节的斜杠、由小写字母、数字、`_` 或 `-` 构成的名称,以及名称后紧接输入末尾或空白的形式。它将名称后的每个字节作为 `rawInput` 返回,其中包括分隔空白;消费方负责各命令专用的语法,只能执行该语法允许的规范化。 +----- -处理器返回 `success` 或 `error`,并可附带 UI 文本。若更丰富的呈现由一条更早的领域事件持有,成功的处理器还可返回 `sourceEventSeq`;生命周期不变量要求该引用指向同一会话中更早的一条非命令事件。适配器直接渲染结果,结果绝不进入模型历史。注册表绝不会隐式地把 `rawInput` 提交给 agent;命令生产方可以通过接收命令的 `Agent` 显式安排模型可见工作,此时该生产方负责由此产生的消息约定。注册表会同时等待处理器完成和所提供的中止信号,以先发生者为准,但不响应中止的处理器可能在调用方停止等待后继续产生自身的外部副作用。 + +## 使用本包 -## 组合 +当交互式 UI 希望用户用斜杠命令而非模型提示词驱动 agent 侧行为时,组合此服务。无 UI 的演示主干和 ACP(Agent Client Protocol)自动化不提供命令适配器,也不需要它。 -随产品交付的 `dsh` 基础组合会挂载此服务,Web 客户端通过它分派命令。无 UI 的演示主干和 ACP(Agent Client Protocol)自动化不提供命令适配器。自定义交互式组合与命令生产方会显式挂载 `@deepseek-ai/dsh-commands`。 +### 注册命令 +插件用 `ctx.commands.register()` 注册命令:小写名称、在发现界面中展示的描述、可选的 `input` 提示,以及针对接收 agent 运行的处理器。 + +```text +ctx.commands.register({ + name: 'plan', + description: 'Enter plan mode', + input: { hint: '' }, + handler: ({ agent, rawInput }) => { + // Runs directly against the agent; no model message is created. + return { kind: 'success', text: 'plan mode selected' } + }, +}) +``` + +处理器返回 `success` 或 `error`,并可附带由适配器渲染的 UI 文本。`recordInput` 默认为 true;若载荷由命令自己的权威领域事件持有,命令会将 `recordInput` 设为 false,避免会话日志重复记录该输入。同一作用域内重复注册同名命令会抛出异常。 + +### 命令语法 + +命令行的第 0 字节必须是斜杠,随后是小写名称(可含字母、数字、`_` 或 `-`),再之后是输入末尾或空白。名称之后的每个字节——包括分隔空白——都是该命令的 `rawInput`,命令自己拥有其专属语法。不符合命令语法、或名称未知的行会被适配器拒绝,而不是变成模型提示词。 + +### 限定到 agent 的命令 + +普通注册全局生效。挂载在 agent 自身上下文之下的命令生产插件会声明 `commands` 注入,并注册精确限定到该 agent 的命令;该定义只对这个 agent 遮蔽同名的全局定义。 + +### 图片附件 + +命令可以声明 `input.images` 以接受 composer 图片附件。执行器负责声明的强制执行:把图片发给未声明的命令、附件存储缺失或批量超出限制,都会在处理器运行前以错误结果结算。通过准入的图片以冻结的有序 `ImageBlock` 数组挂在 `invocation.attachments` 上交给处理器,其模型可见用途由处理器负责——注册表本身绝不定时调度它们。 + +### 从适配器分派 + +交互式适配器调用 `execute(agent, line, images, signal)`,传入确切的接收 agent、完整命令行与本次提交的图片。它返回已结算的 `CommandExecution`——规范化结果加生命周期配对 `commandId`——语法无效或名称未知时返回 `undefined`。`list(agent)` 与 `find(agent, name)` 在应用 agent 作用域遮蔽后服务发现。 + +### 取消 + +调用方的中止信号会让注册表停止等待处理器;无视信号的处理器可能在调用方停止等待后继续产生自身的外部副作用。被取消或抛异常的处理器在日志中以 `command/done` 错误结算。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +可观察行为已在[使用本包](#use-this-package)中说明;本节解释注册表的构建方式与其约定的归属。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `CommandRuntime` 服务:注册、作用域、分派、生命周期事件 | +| [`src/types.ts`](src/types.ts) | 命令定义、描述符、执行与结果类型 | +| [`src/brand.ts`](src/brand.ts) | 生命周期配对 id 的 `CommandId` brand | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:按会话日志配对 `command/run` 与 `command/done` | + +### 生命周期事件 + +`execute()` 会生成一个 `commandId`,在处理器运行前追加 `command/run`,并在结算时追加携带结果类型与原样文本的 `command/done`;确切载荷字段见 [`src/index.ts`](src/index.ts)。成功结果可以通过 `sourceEventSeq` 指向更早的一条非命令权威领域事件;处理器抛出或被中止时以 `kind: 'error'` 结算。两个事件都是直接独立追加的仅写日志事件:没有轮次包裹它们,持久化机制会在常规检查点和销毁期间排空这些事件。未通过准入的输入(语法无效或名称未知)不记录任何事件。 + +### 作用域 + +注册表通过 `ScopedLayers` 维护全局层与按 agent 的作用域层,并按 agent 合并视图。子级注入形态——挂载在 `agent.ctx` 之下的命令生产插件声明自身的 `commands` 注入——保留了 agent 作用域,同时不会让核心 agent loop 依赖 UI 服务。同一层内的名称重复会在注册时失败;注册或移除命令时,系统会通知每个 `commands/change` 观察者,使运行中的适配器能够刷新发现结果。观察者失败会写入日志,既不能否决注册表变更,也不能阻止后续观察者运行。 + +### 图片准入 + +图片强制执行发生在执行器而非 composer 中:通过准入的批量经 `admitEncodedImages` 提交给 `attachments` 存储,被拒绝的批量不发布任何持久化对象;取消会在处理器运行前被处理,因此重试的调用方绝不会重复状态。无法使用图片的处理器会返回错误,使分发方 composer 保留原件。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从共享命令词汇逐步进入设计证据与相邻表面。 + +- [命令子系统参考](../../../docs/subsystems/commands.zh.md)——注册表语义、输入元数据与 `ctx.commands` 的 cordis 接口面。 +- [命令注册 Agent Note](../../../.agents/notes/implemented/feature/2026-07-19-plugin-command-registration.zh.md)——此服务背后的边界与分发约定。 +- [交互组映射](../README.zh.md)——相邻的审批、权限与问答包。 +- [Plan mode 包](../../plan/plan-mode/README.zh.md)——一个驱动模型可见工作的随附命令生产方。 + +----- + + ## 模型体验 ### 直接面向用户的命令 #### 模型看到的内容 -注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 +注册表自身不会提交任何内容。已知斜杠命令在 UI 命令平面执行,其 `CommandResult` 文本不会作为用户消息提交。已交付的适配器会拒绝未知斜杠命令输入,而不是将其变成模型提示词。命令生产方可以显式使用接收命令的 `Agent`;例如,[`dsh-plan-mode`](../../plan/plan-mode/README.zh.md#model-and-human-interactions)在选择 plan mode 后,会提交 `/plan [message]` 中的可选消息。图片附件遵循同一规则:执行器只负责把它们准入为持久化附件对象,是否以及如何成为模型可见的消息内容由声明接受的生产方决定。 #### Token 影响 @@ -34,7 +129,22 @@ 注册表元数据、命令输入和直接输出绝不会进入模型请求,也不会影响其缓存。发生变更的领域负责之后产生的所有缓存影响。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 + + + + +这些限制说明注册表不提供什么。它们是当前包约束,不是 UI 积压事项。 - **仅支持非结构化文本输入**:表单、补全 schema 和类型化参数仍由各命令自行解析。 - **副作用采用协作式取消**:中止后,分发会停止等待;处理器必须遵循信号,才能停止已经进入外部系统的工作。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 84e2d41b88..f5eebc76d1 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UIs", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -64,6 +64,7 @@ "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { + "@deepseek-ai/dsh-util-crypto": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 9e078938ed..b0b3a0dd45 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -4,6 +4,7 @@ */ import { Context } from '@deepseek-ai/cordis' +import { randomUUID } from '@deepseek-ai/dsh-util-crypto' import type { Agent } from '@deepseek-ai/dsh-agent' import { AttachmentError, admitEncodedImages } from '@deepseek-ai/dsh-attachment' import type { EncodedImageAttachment } from '@deepseek-ai/dsh-attachment/types' @@ -256,7 +257,7 @@ export class CommandRuntime extends TypertRemoteService { /** Monotonic per-instance counter behind {@link mintCommandId}. */ private commandSeq = 0 /** Instance token keeping minted ids unique across process restarts over one resumed log. */ - private readonly instanceToken = crypto.randomUUID().slice(0, 8) + private readonly instanceToken = randomUUID().slice(0, 8) constructor(ctx: Context) { super(ctx, 'commands') diff --git a/packages/interaction/commands/src/types.ts b/packages/interaction/commands/src/types.ts index f8e375774d..621a5971ba 100644 --- a/packages/interaction/commands/src/types.ts +++ b/packages/interaction/commands/src/types.ts @@ -59,7 +59,7 @@ export interface CommandDescriptor { /** * Producer record for one command invocation (the `command/run` event's * source slot). Merge-extensible sum type mirroring `MessageSourceMap`'s - * shape; minimal today because every executor caller is a human-facing UI + * shape; minimal because every executor caller is a human-facing UI * surface dispatching a human-typed line, so the sole variant is `user`. */ export interface CommandSourceMap { diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 755bb0ab2f..a95ee024dc 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -483,6 +483,12 @@ describe('image attachments', () => { ...input.name === undefined ? {} : { name: input.name }, }) }), + validateImageBatch(inputs: readonly unknown[]) { + const validate = AttachmentStore.prototype as unknown as { + validateImageBatch(this: unknown, batch: readonly unknown[]): void + } + validate.validateImageBatch.call(this, inputs) + }, // The real base-class batch method over this double's limits and members. saveImages(inputs: readonly unknown[]) { return (AttachmentStore.prototype.saveImages as (this: unknown, batch: readonly unknown[]) => Promise).call(this, inputs) @@ -585,7 +591,9 @@ describe('image attachments', () => { const store = storeOf() store.saveImage.mockImplementationOnce((input: { mediaType: string }) => { controller.abort('operator cancelled during admission') - return Promise.resolve({ attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1 }) + return Promise.resolve({ + attachmentId: 'att-late', mediaType: input.mediaType, bytes: 3, width: 1, height: 1, + }) }) ctx.provide('attachments', store) const { agent } = await mintAgentScope(ctx, 'a') diff --git a/packages/interaction/commands/tsconfig.json b/packages/interaction/commands/tsconfig.json index 7f7bfd9ac0..436f5c9472 100644 --- a/packages/interaction/commands/tsconfig.json +++ b/packages/interaction/commands/tsconfig.json @@ -37,6 +37,9 @@ }, { "path": "../../typert/protocol" + }, + { + "path": "../../util/crypto" } ] } diff --git a/packages/interaction/permission-presets/README.i18n.yaml b/packages/interaction/permission-presets/README.i18n.yaml index 7e7359526c..53ee5a2669 100644 --- a/packages/interaction/permission-presets/README.i18n.yaml +++ b/packages/interaction/permission-presets/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/interaction/permission-presets/README.md -README.md: 2b671f9e6e835c529453dc7d4ca7bc2eff01f2b6 -README.zh.md: 686138c1f2b0b3770771d0e6d6a785ed17cc8621 +README.md: a704b1107ea571a0ae40b62d7c1fffb97622b61d +README.zh.md: f9dc05bbbda2f6de8587d88229c1fbca37fe4289 diff --git a/packages/interaction/permission-presets/README.md b/packages/interaction/permission-presets/README.md index 2b671f9e6e..a704b1107e 100644 --- a/packages/interaction/permission-presets/README.md +++ b/packages/interaction/permission-presets/README.md @@ -1,20 +1,122 @@ +--- +description: "User-facing permission presets for users and maintainers choosing, configuring, or debugging the Permissions selector that bundles sandbox mode with an approval policy." +kind: "package-reference" +--- + # @deepseek-ai/dsh-permission-presets English | [中文](README.zh.md) -User-facing permission presets through `ctx.permissionPresets` ([`PermissionPresetService`](src/index.ts)). Each configured name bundles `sandbox/mode` with `approval/policy`; the defaults are `workspace-write` (`workspace-write` + `ask`) and `danger-full-access` (`danger-full-access` + `never`). UI adapters may expose the table as one selector, while sandbox execution and approval continue to consume their own knobs. +## Summary -`set(session, name)` records a changed selection in a log-only `permissionPresets/preset` event, then calls each knob's setter only when its effective value changes. The selection event precedes the knob events and preserves user intent when presets share a bundle; a net-zero selection appends nothing. `current(events)` prefers a still-matching recorded selection, then the first matching table entry, and otherwise returns `custom`. Clients may display `custom` as the current value, but cannot select it. +`dsh-permission-presets` gives a deployment one user-facing Permissions selector that bundles two independent enforcement knobs — the sandbox mode and the approval policy — into named presets. Selecting a preset applies the sandbox mode and approval policy together, while each knob keeps its own value, so sandbox execution, approval, prompt narration, and replay each read their own setting. The default table ships `workspace-write` (workspace-write + ask) and `danger-full-access` (danger-full-access + never); a knob combination matching no preset reads back as the derived `custom`, which clients may display but never select. The service also owns the `permission` settings namespace whose default applies only when a later session is created, and two optional children — a `permissions` session projection and the `/permission` command — expose the same surface to the Web client. Mounting it requires a confining bash executor and the approval service; it owns no enforcement itself. -The service owns the `permissionPresets` Settings namespace. Its `defaultPreset` is the default for future sessions: the composition entry uses `Config.defaultPreset`, or infers the preset matching the composed sandbox and approval defaults when omitted. A committed Settings change is read when the next session is created; creation pins `permissionPresets/preset`, `sandbox/mode`, and `approval/policy` into that session, so later changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. Mounting the service also sweeps already-live sessions, so an HMR replacement pins any session created while the plugin was absent. +## Table of Contents -The service requires a confining `ctx.shell` executor and `ctx.approval`. A table entry named `custom` throws at load. When composition defaults match no preset, the plugin requires an explicit `defaultPreset`; an independently constructed zero-event session may still derive `custom`. See the [sandbox switching design](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -Two optional children ship the product surfaces over the same service: a `permissions` session-projection unit (`src/types.ts` declares the key; the unit folds the three whole-value knob events and views the select — table options plus a current-only `custom` — over the composition defaults) and the `/permissionPresets` command (bare invocation reports the current preset and the table; a preset argument switches through `set`). Each child activates only when its registry (`ctx.sessionProjections` / `ctx.commands`) is composed. +----- + +## Use this package + +Choose this service when a deployment wants to offer users one Permissions selector instead of separate sandbox and approval controls. It bundles the knobs; execution and approval keep their own values, so removing the package later leaves the last selection in effect. + +### Configuring presets + +The plugin config defines the preset table and the default for fresh sessions. Each preset name bundles one sandbox mode with one approval policy; `name` and `description` are optional client presentation. + +```yaml +- name: '@deepseek-ai/dsh-permission-presets' + config: + presets: + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + defaultPreset: workspace-write +``` + +| Field | Default | Meaning | +|---|---|---| +| `presets` | `workspace-write`, `danger-full-access` | Table of preset name → sandbox/approval bundle | +| `defaultPreset` | inferred | Preset pinned into fresh sessions; required when composition defaults match no preset | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-permission-presets) is the exhaustive source for every accepted field and its JSDoc. The name `custom` is reserved for the derived not-a-preset state and cannot name a table entry. Mounting requires a confining bash executor (one that reports a `sandboxMode`) and the approval service. + +### Switching presets + +Switching to a preset changes only the knobs whose effective value differs; selecting the preset already in effect changes nothing. The current value resolves as the still-matching last recorded selection, else the first matching table entry, else `custom`. Users switch through the `/permission` command: a bare invocation reports the current preset and the available table, and a preset argument switches to it. + +### What users see + +Clients render the select with every switchable preset in table order, plus `custom` shown exactly while it is current. `custom` is display-only — callers can switch away from an unmatched knob combination but cannot select or persist a named custom preset through this service. + +### Session defaults + +The `permission` settings namespace holds `defaultPreset` for future sessions: session creation reads it, applies it to the sandbox mode and approval policy, and records the applied preset as a `permission/preset` selection. Later settings changes never alter an existing session. A resumed seed, including an explicitly empty one marked by `session/end-seed`, preserves its effective permission and receives only missing durable facts rather than the latest user default. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The observable behavior is covered in [Use this package](#use-this-package); this section explains the write path, the read side, and the optional children. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `PermissionPresetService`: preset table, write path, settings namespace, session pinning, children | +| [`src/types.ts`](src/types.ts) | `permissions` projection-key declaration and select payload types | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion validating that `permission/preset` names a resolvable preset | + +### Write path + +`apply()` resolves the preset, appends `permission/preset` only when the effective preset changes, then writes each changed knob through its canonical setter — `setSandboxMode` from `dsh-sandbox-policy` and `setApprovalPolicy` from `dsh-user-approval`. The selection event precedes the knob events so user intent survives when two presets share a bundle; a net-zero selection appends nothing. + +### Read side and `custom` + +`current(events)` folds the three whole-value knob events over the composition defaults (`ctx.shell.sandboxMode` and the approval config). A still-matching last selection wins shared-bundle ties; otherwise the first table match wins; otherwise the derived `CUSTOM_PRESET` is returned. The `permissions` projection unit applies the same fold one event at a time and serves the select to clients. + +### Session pinning and blank reuse + +Mounting pins every live and future session: a genuinely fresh session gains the default preset and both knob facts, while seeded or partially initialized sessions keep their effective knob values and gain only missing durable facts. + +### Optional children + +The `permissions` projection unit registers only when a `ctx.sessionProjections` registry is composed; the `/permission` command registers only when a `ctx.commands` registry is composed. Headless assemblies without either registry stay unaffected. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the preset vocabulary to the enforcement knobs and the design rationale. + +- [Permission presets subsystem reference](../../../docs/subsystems/permission-presets.md) — the preset table, the select payload, and the `ctx.permissionPresets` cordis surface. +- [Sandbox switching design Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) — how sandbox mode and approval policy compose and switch. +- [Approval subsystem reference](../../../docs/subsystems/approval.md) — the approval policy knob this service bundles. +- [Interaction group map](../README.md) — adjacent command, approval, and question packages. + +----- + + ## Model Experience -Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permissionPresets/preset` itself is log-only. +Indirectly, through `dsh-user-approval` and `dsh-tool-bash`, which render the approval-policy prompt, switch notice, and sandboxed tool outcomes selected by this service's knob events; `permission/preset` itself is log-only. #### KV Cache effect @@ -22,7 +124,22 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work + + + +These limits define what the preset service does not offer. They are current package constraints, not a permission-system comparison. + - **Only two mechanism knobs are bundled** — presets select sandbox mode and approval policy; an agent/profile choice is not part of `PresetSpec` yet. - **`custom` is derived-only** — callers can switch away from an unmatched knob combination but cannot target or persist a named custom preset through this service. - **The preset table is process-level** — configuration is fixed for the plugin lifetime; changing available presets requires reloading the plugin. -- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permissionPresets` section in `settings.yaml` is updated or reset. +- **Stored defaults must remain in the preset table** — removing the referenced preset makes Permission settings registration fail until the `permission` section in `settings.yaml` is updated or reset. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/interaction/permission-presets/README.zh.md b/packages/interaction/permission-presets/README.zh.md index 686138c1f2..f9dc05bbbd 100644 --- a/packages/interaction/permission-presets/README.zh.md +++ b/packages/interaction/permission-presets/README.zh.md @@ -1,28 +1,145 @@ +--- +description: "面向用户的权限预设:供选择、配置或排查把沙箱模式与审批策略捆绑在一起的 Permissions 选择器的用户与维护者阅读。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-permission-presets [English](README.md) | 中文 -通过 `ctx.permissionPresets`([`PermissionPresetService`](src/index.ts))提供面向用户的权限预设。每个配置名称都会将 `sandbox/mode` 与 `approval/policy` 组成一组;默认项为 `workspace-write`(`workspace-write` + `ask`)和 `danger-full-access`(`danger-full-access` + `never`)。UI 适配器可以将该表作为单个选择器公开,而沙箱执行与审批仍分别消费各自的调节项。 +## 概述 -`set(session, name)` 会先在仅写日志的 `permissionPresets/preset` 事件中记录已变更的选择,再仅对实际值发生变化的调节项调用 setter。选择事件先于调节项事件,并在多个预设共享同一组取值时保留用户意图;净变化为零的选择不会追加任何内容。`current(events)` 优先返回仍与当前调节项匹配的已记录选择,其次返回表中第一个匹配项,否则返回 `custom`。客户端可以把 `custom` 显示为当前值,但不能选择它。 +`dsh-permission-presets` 为部署提供一个面向用户的 Permissions 选择器,把两个独立的执行旋钮——沙箱模式与审批策略——捆绑为具名预设。选择预设会同时应用沙箱模式与审批策略,而每个旋钮各自保留自己的值,因此沙箱执行、审批、提示词叙述与回放都读取各自的设置。默认表提供 `workspace-write`(workspace-write + ask)与 `danger-full-access`(danger-full-access + never);不匹配任何预设的旋钮组合会读回推导出的 `custom`,客户端可以显示它,但不能选择它。该服务还拥有 `permission` 设置命名空间,其默认值只在之后创建会话时生效;两个可选子功能——`permissions` 会话投影单元与 `/permission` 命令——向 Web 客户端暴露同一表面。挂载它需要具有约束能力的 bash 执行器与审批服务;它自身不拥有任何执行权。 -该服务拥有 `permissionPresets` Settings namespace。其 `defaultPreset` 是未来会话的默认值:组合项使用 `Config.defaultPreset`;省略时,则推断与组合后的沙箱和审批默认值匹配的 preset。已提交的 Settings 变更会在下一个会话创建时读取;创建过程将 `permissionPresets/preset`、`sandbox/mode` 和 `approval/policy` 固定到该会话中,因此后续变更绝不会改变现有会话。恢复的 seed,包括由 `session/end-seed` 标记的显式空 seed,都会保留其有效权限,只补齐缺失的持久事实,而不会采用最新的用户默认值。挂载服务时还会遍历所有已存活会话,因此 HMR(热模块替换)会固定插件缺席期间创建的所有会话。 +## 目录 -该服务要求存在具有约束能力的 `ctx.shell` 执行器和 `ctx.approval`。表中名为 `custom` 的条目会在加载时抛出异常。当组合默认值与任何 preset 都不匹配时,插件要求显式配置 `defaultPreset`;独立构造的零事件会话仍可能推导出 `custom`。详见[沙箱切换设计](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -两个可选子功能在同一服务之上提供产品界面:`permissions` 会话投影单元(`src/types.ts` 声明该 key;单元以组合默认值为基础折叠三个全量值可调参数事件,并生成选择器视图,其中包含表内选项和仅作当前值的 `custom`)与 `/permissionPresets` 命令(不带参数调用时报告当前预设与表;预设参数经 `set` 切换)。每个子功能仅在其注册表(`ctx.sessionProjections` / `ctx.commands`)被组合时激活。 +----- + +## 使用本包 + +当部署希望向用户提供一个 Permissions 选择器、而非分离的沙箱与审批控件时,选择此服务。它捆绑旋钮;执行与审批各自保留自己的取值,因此以后移除本包,最后一次取值依然生效。 + +### 配置预设 + +插件配置定义预设表与新会话的默认值。每个预设名称把一个沙箱模式与一个审批策略捆绑为一组;`name` 与 `description` 是可选的客户端呈现。 + +```yaml +- name: '@deepseek-ai/dsh-permission-presets' + config: + presets: + workspace-write: + sandbox: workspace-write + approval: ask + danger-full-access: + sandbox: danger-full-access + approval: never + defaultPreset: workspace-write +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `presets` | `workspace-write`、`danger-full-access` | 预设名称 → 沙箱/审批捆绑的表 | +| `defaultPreset` | 推断 | 固定到新会话的预设;组合默认值不匹配任何预设时必填 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-permission-presets)是每个受支持字段及其 JSDoc 的穷尽式真源。`custom` 这个名称保留给推导出的非预设状态,不能作为表条目。挂载需要具有约束能力的 bash 执行器(会报告 `sandboxMode` 的执行器)与审批服务。 + +### 切换预设 + +切换到某个预设只改变实际值不同的旋钮;再次选择当前已生效的预设不会产生任何变化。当前值解析顺序为:仍匹配的最近一次记录选择,其次表中第一个匹配项,否则为 `custom`。用户通过 `/permission` 命令切换:不带参数调用时报告当前预设与可用表,带预设参数时切换过去。 + +### 用户看到什么 + +客户端渲染选择器:按表顺序列出每个可切换预设,并在当前值为 `custom` 时将其附加在末尾。`custom` 仅供显示——调用方可以从不匹配的旋钮组合切换出去,但不能通过此服务选中或持久化一个具名 custom 预设。 + +### 会话默认值 + +`permission` 设置命名空间为未来会话持有 `defaultPreset`:创建会话时读取它,将其应用于沙箱模式与审批策略,并把应用的预设记录为一次 `permission/preset` 选择。之后的设置变更绝不会改变现有会话。恢复的 seed(包括由 `session/end-seed` 明确标记的空 seed)会保留其有效权限,并只接收缺失的持久事实,而不会接收最新用户默认值。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +可观察行为已在[使用本包](#use-this-package)中说明;本节解释写入路径、读取侧与可选子功能。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `PermissionPresetService`:预设表、写入路径、设置命名空间、会话固定、子功能 | +| [`src/types.ts`](src/types.ts) | `permissions` 投影键声明与选择器载荷类型 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验 `permission/preset` 指向可解析的预设 | + +### 写入路径 + +`apply()` 解析预设,仅当有效预设变化时追加 `permission/preset`,然后通过各自的权威 setter——`dsh-sandbox-policy` 的 `setSandboxMode` 与 `dsh-user-approval` 的 `setApprovalPolicy`——写入每个变化的旋钮。选择事件先于旋钮事件,因此在两个预设共享同一组取值时保留用户意图;净变化为零的选择不追加任何内容。 + +### 读取侧与 `custom` + +`current(events)` 在组合默认值(`ctx.shell.sandboxMode` 与审批配置)之上折叠三个全量值旋钮事件。仍匹配的最近选择在共享捆绑时胜出;否则表中第一个匹配项胜出;否则返回推导出的 `CUSTOM_PRESET`。`permissions` 投影单元逐事件应用同一折叠,并向客户端提供选择器。 + +### 会话固定与空白复用 + +挂载时会固定所有存活与未来的会话:真正全新的会话获得默认预设与两个旋钮事实,而 seed 会话或部分初始化的会话保留其有效旋钮值,只补充缺失的持久事实。 + +### 可选子功能 + +`permissions` 投影单元仅在组合了 `ctx.sessionProjections` 注册表时注册;`/permission` 命令仅在组合了 `ctx.commands` 注册表时注册。两者都未组合的无头装配不受影响。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从预设词汇逐步进入执行旋钮与设计依据。 + +- [权限预设子系统参考](../../../docs/subsystems/permission-presets.zh.md)——预设表、选择器载荷与 `ctx.permissionPresets` 的 cordis 接口面。 +- [沙箱切换设计 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)——沙箱模式与审批策略如何组合与切换。 +- [审批子系统参考](../../../docs/subsystems/approval.zh.md)——此服务捆绑的审批策略旋钮。 +- [交互组映射](../README.zh.md)——相邻的命令、审批与问答包。 + +----- + + ## 模型体验 -间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者会渲染由此服务的可调参数事件所选择的审批策略提示词、切换通知和沙箱工具结果;`permissionPresets/preset` 本身只写入日志。 +间接地,通过 `dsh-user-approval` 和 `dsh-tool-bash`:二者渲染由此服务的旋钮事件所选择的审批策略提示词、切换通知与沙箱工具结果;`permission/preset` 本身只写入日志。 #### KV Cache 影响 不会直接使缓存失效;具名消费方拥有所有请求前缀变更。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **只组合两个机制级可调参数**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 -- **`custom` 只能推导得出**:调用方可以从不匹配的调节项组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。 + + + +这些限制说明预设服务不提供什么。它们是当前包约束,不是权限系统对比。 + +- **只组合两个机制级旋钮**:预设选择沙箱模式和审批策略;agent(智能体)/profile 选择尚未纳入 `PresetSpec`。 +- **`custom` 只能推导得出**:调用方可以从不匹配的旋钮组合切换出去,但无法通过此服务选中或持久化一个名为 custom 的预设。 - **预设表是进程级配置**:配置在插件生命周期内固定;更改可用预设必须重新加载插件。 -- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permissionPresets` 分节。 +- **已存储的默认值必须保留在 preset 表中**:移除被引用的 preset 会导致权限设置注册失败,直到更新或重置 `settings.yaml` 中的 `permission` 分节。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/interaction/permission-presets/package.json b/packages/interaction/permission-presets/package.json index 59e7f4abeb..a9e3313204 100644 --- a/packages/interaction/permission-presets/package.json +++ b/packages/interaction/permission-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-permission-presets", "description": "User-facing permission presets (ctx.permissionPresets) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/permission-presets/src/index.ts b/packages/interaction/permission-presets/src/index.ts index fee29c2644..2366ff13f9 100644 --- a/packages/interaction/permission-presets/src/index.ts +++ b/packages/interaction/permission-presets/src/index.ts @@ -100,6 +100,22 @@ export interface KnobState { approval: ApprovalPolicy | null } +declare module '@deepseek-ai/dsh-session-projection/types' { + interface SessionProjectionStateMap { + permissions: KnobState + } +} + +const knobStateSchema: zod.ZodType = zod.object({ + preset: zod.string().nullable(), + sandbox: zod.union([ + zod.literal('read-only'), + zod.literal('workspace-write'), + zod.literal('danger-full-access'), + ]).nullable(), + approval: zod.union([zod.literal('ask'), zod.literal('never')]).nullable(), +}).strict() + /** State for the empty log: every knob at its composition default. */ const EMPTY_KNOBS: KnobState = { preset: null, sandbox: null, approval: null } @@ -243,10 +259,10 @@ export class PermissionPresetService extends Service { ctx.inject(['sessionProjections'], (projectionCtx) => { projectionCtx.sessionProjections.register<'permissions', KnobState>({ key: 'permissions', - schema: selectSchema, + stateSchema: knobStateSchema, init: () => EMPTY_KNOBS, apply: applyKnobEvent, - view: state => this.selectFor(state), + wire: { viewSchema: selectSchema, view: state => this.selectFor(state) }, stateVersion: 1, }) }) diff --git a/packages/interaction/tool-ask-user/README.i18n.yaml b/packages/interaction/tool-ask-user/README.i18n.yaml index ed6cd6a35f..03a1b41c80 100644 --- a/packages/interaction/tool-ask-user/README.i18n.yaml +++ b/packages/interaction/tool-ask-user/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/interaction/tool-ask-user/README.md -README.md: bf2ba369fca36c462c6155ed2354ab3d0e930dd0 -README.zh.md: 671bea0132093b890de4ce27095fa60dac396276 +README.md: ec123b206bb4f2d91680b09e5f5dd10efc4c3d0d +README.zh.md: e86e8cee1561d52af36dcba1dca3bfe14ec98da4 diff --git a/packages/interaction/tool-ask-user/README.md b/packages/interaction/tool-ask-user/README.md index bf2ba369fc..ec123b206b 100644 --- a/packages/interaction/tool-ask-user/README.md +++ b/packages/interaction/tool-ask-user/README.md @@ -1,26 +1,106 @@ +--- +description: "The model-facing ask_user_question tool over the user-questions seam, for users and maintainers composing or debugging interactive agent surfaces." +kind: "package-reference" +--- + # @deepseek-ai/dsh-tool-ask-user English | [中文](README.zh.md) -Model-facing `ask_user_question` tool over `ctx.userQuestions`. It lets the model ask the human a concise question when it needs confirmation, a choice, or missing information before continuing. +## Summary -## Tool +`dsh-tool-ask-user` gives the model one tool — `ask_user_question` — for asking the human a concise question when it needs confirmation, a choice, or missing information before continuing. The tool pauses until the first scoped answerer accepts the request, then feeds that answer back into the agent loop as an ordinary tool result, so no loop mechanics change. The tool returns the canonical `{ answers: [...] }` shape, rendered as compact JSON text. It renders no UI itself and does not know how input is collected; the Web client contributes its answerer through Remote Events. A runtime-owned child agent cannot ask the user; it must include the unresolved question in its final result. -`ask_user_question` accepts: +## Table of Contents -- `questions` — required non-empty array of question objects. -- `id` — required stable id on each question, echoed in the answer. -- `question` — required question text for each question. -- `header` — optional short heading. -- `options` — optional choices with `label` and `description`. If recommending a choice, put it first and append `(Recommended)` to that label. -- `multi_select` — whether that question may return more than one selected option. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -The tool calls `ctx.userQuestions.ask()` and returns canonical `{ answers: [{ id, selected, custom? }] }`. `selected` contains option labels; `custom` carries a free-form answer, supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`. +----- -## Role + +## Use this package -This is the Consumer package for the user-questions seam. It does not render UI and does not know how input is collected; it only translates model arguments into `AskUserQuestionRequest` and returns the human answer to the agent loop. +Compose this plugin wherever the model should be able to pause for a human decision: it provides the `ask_user_question` tool and needs the `ctx.userQuestions` seam with an answerer that accepts the scoped request. Without one, the tool call fails with an error instead of degrading. +### When to call the tool + +The model calls `ask_user_question` when it needs confirmation, a choice, or missing information before proceeding. Send one or more questions, each with a stable `id` that is echoed in the answer; a recommended option goes first with `(Recommended)` appended to its label. + +```json +{ + "questions": [ + { + "id": "cleanup", + "question": "Proceed with the destructive cleanup?", + "header": "Confirm", + "options": [ + { "label": "Yes, delete them (Recommended)", "description": "Removes the three stale files." }, + { "label": "No, keep them", "description": "Aborts the cleanup." } + ] + } + ] +} +``` + +### What the model gets back + +The tool returns one answer object per question: `selected` holds the chosen option labels, and `custom` carries a free-form answer — supplementing `selected` for a multi-select question and overriding it for a single-select question. The Native renderer preserves the compact JSON text shape. + +```json +{ "answers": [{ "id": "cleanup", "selected": ["Yes, delete them (Recommended)"] }] } +``` + +### When the call fails + +The tool call blocks until the human answers and cancels only through the turn's signal. No accepting answerer, an aborted call, or a caller that is not the exact live runtime root each settles as an error the model sees in the tool result — most notably, a live child agent owned by another agent is rejected (`DELEGATED_CALLER`) and must include the unresolved question or decision in its final result. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The observable behavior is covered in [Use this package](#use-this-package); this section explains the tool definition and its relationship to the seam. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Tool registration: `ask_user_question` schema, execute path, result render | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; the seam owns execution relations) | + +### Consumer role + +The plugin registers one `defineTool` entry on `ctx.tools` with injects `['tools', 'userQuestions']`. `execute` maps model arguments into an `AskUserQuestionRequest`, forwards the exact calling agent and the turn's signal, and maps the accepted answer back into the canonical `answers` array. The seam owns identity checks, intent validation, waterfall dispatch, and the error taxonomy; this package only translates. + +### Result rendering + +The `render` output projects the structured value to a single text block via `JSON.stringify`, which is why the model-facing result is compact JSON rather than a richer content-block vocabulary. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the tool surface to the seam contract and its answerer waterfall. + +- [User interaction subsystem reference](../../../docs/subsystems/user-questions.md) — the service contract, question vocabulary, and answerer waterfall behind this tool. +- [Tool catalog](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user) — the generated `ask_user_question` schema. +- [user-questions package](../user-questions/README.md) — the seam this tool consumes. +- [Interaction group map](../README.md) — adjacent approval and command surfaces. + +----- + + ## Model Experience ### Tool schema @@ -53,6 +133,21 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work + + + +These limits define when the tool is a poor fit. They are current package constraints, not a UI backlog. + - **A pending question blocks the tool call until the human answers** — the tool declares no `timeout-policy` budget; cancellation rides the turn's `exec.signal` only. - **Runtime-owned subagents cannot ask the user** — `ask_user_question` rejects a live child owned by another agent with `DELEGATED_CALLER`; the child must include the unresolved question or decision in its final result. Durable lineage does not decide this boundary, so a lineage-bearing session resumed as a runtime root may ask normally. - **Native answers render as JSON text** — the canonical value remains structured, but the model-facing result uses compact JSON rather than a richer content-block vocabulary. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/interaction/tool-ask-user/README.zh.md b/packages/interaction/tool-ask-user/README.zh.md index 671bea0132..e86e8cee15 100644 --- a/packages/interaction/tool-ask-user/README.zh.md +++ b/packages/interaction/tool-ask-user/README.zh.md @@ -1,33 +1,113 @@ +--- +description: "基于用户交互 seam 的模型侧 ask_user_question 工具;供组合或排查交互式 agent 表面的用户与维护者阅读。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-tool-ask-user [English](README.md) | 中文 -模型侧 `ask_user_question` 工具,基于 `ctx.userQuestions` 实现。当模型需要确认、选择结果或缺失的信息才能继续时,它可以借此向用户提出简明问题。 +## 概述 -## 工具 +`dsh-tool-ask-user` 为模型提供一个工具——`ask_user_question`——用于在需要确认、选择结果或缺失的信息才能继续时,向用户提出简明问题。工具会暂停,直到首个作用域 answerer 接受请求,然后把回答作为普通工具结果送回 agent loop(智能体循环),因此循环机制没有任何变化。工具返回规范的 `{ answers: [...] }` 结构,并以紧凑的 JSON 文本形式呈现。它自身不渲染 UI,也不了解输入的收集方式;Web Client 通过 Remote Events 提供 answerer。运行时中归属于其他 agent 的子级不能向用户提问;它必须在最终结果中包含尚未解决的问题。 -`ask_user_question` 接受以下参数: +## 目录 -- `questions`:必填的非空问题对象数组。 -- `id`:每个问题必填的稳定 id,会原样包含在回答中。 -- `question`:每个问题必填的问题文本。 -- `header`:可选的简短标题。 -- `options`:可选选项,包含 `label` 和 `description`。如需推荐某个选项,请将其置于首位,并在该标签末尾追加 `(Recommended)`。 -- `multi_select`:该问题是否可以返回多个选中的选项。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -工具调用 `ctx.userQuestions.ask()`,并返回规范的 `{ answers: [{ id, selected, custom? }] }`。`selected` 包含选项标签;`custom` 携带自由填写的回答,对于多选题会补充 `selected`,对于单选题则会覆盖它。Native 渲染器会保留紧凑的 JSON 文本形式 `{ "answers": [{ "id": "...", "selected": ["..."], "custom": "..." }] }`。 +----- -## 职责 + +## 使用本包 -此包是用户交互 seam 的Consumer 包。它不渲染 UI,也不了解输入的收集方式;它只将模型参数转换为 `AskUserQuestionRequest`,并把用户回答返回给 agent loop(智能体循环)。 +凡模型应当能够暂停等待人类决定的场景,都可组合此插件:它提供 `ask_user_question` 工具,并且需要带有接受作用域请求的 answerer 的 `ctx.userQuestions` seam。没有 answerer 接受时,工具调用会以错误失败,而不是降级。 +### 何时调用该工具 + +当模型需要确认、选择结果或缺失的信息才能继续时,调用 `ask_user_question`。发送一个或多个问题,每个问题携带稳定的 `id`(回答中会原样包含);推荐选项放在首位,并在标签末尾追加 `(Recommended)`。 + +```json +{ + "questions": [ + { + "id": "cleanup", + "question": "Proceed with the destructive cleanup?", + "header": "Confirm", + "options": [ + { "label": "Yes, delete them (Recommended)", "description": "Removes the three stale files." }, + { "label": "No, keep them", "description": "Aborts the cleanup." } + ] + } + ] +} +``` + +### 模型得到什么 + +工具为每个问题返回一个回答对象:`selected` 保存选中的选项标签,`custom` 携带自由填写的回答——对多选题补充 `selected`,对单选题覆盖它。Native 渲染器保留紧凑的 JSON 文本形式。 + +```json +{ "answers": [{ "id": "cleanup", "selected": ["Yes, delete them (Recommended)"] }] } +``` + +### 调用何时失败 + +工具调用会阻塞到用户作答,并且只能通过当前轮次的信号取消。没有 answerer 接受、调用被中止、或调用方不是确切的存活运行时根,都会以模型在工具结果中看到的错误结算——最值得注意的是,归属于另一个 agent 的存活子级会被拒绝(`DELEGATED_CALLER`),必须在最终结果中包含尚未解决的问题或决定。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +可观察行为已在[使用本包](#use-this-package)中说明;本节解释工具定义及其与 seam 的关系。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 工具注册:`ask_user_question` schema、执行路径、结果渲染 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;执行关系由 seam 拥有) | + +### Consumer 角色 + +该插件以 `['tools', 'userQuestions']` 注入,在 `ctx.tools` 上注册一个 `defineTool` 条目。`execute` 把模型参数映射为 `AskUserQuestionRequest`,转发确切的调用 agent 与当前轮次的信号,并把接受的回答映射回规范的 `answers` 数组。身份检查、意图校验、waterfall 分派与错误分类由 seam 拥有;本包只做转换。 + +### 结果渲染 + +`render` 输出把结构化值经 `JSON.stringify` 投影为单个文本块,因此模型侧结果是紧凑 JSON,而非更丰富的内容块词汇。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从工具表面逐步进入 seam 约定及其 answerer waterfall。 + +- [用户交互子系统参考](../../../docs/subsystems/user-questions.zh.md)——此工具背后的服务约定、问题词汇与 answerer waterfall。 +- [工具目录](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ask-user)——生成的 `ask_user_question` schema。 +- [user-questions 包](../user-questions/README.zh.md)——本工具消费的 seam。 +- [交互组映射](../README.zh.md)——相邻的审批与命令表面。 + +----- + + ## 模型体验 ### 工具 schema #### 模型看到的内容 -模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项和多选标志。 +模型会看到生成的 [`ask_user_question` schema](../../../docs/tool-catalog.zh.md#deepseek-aidsh-tool-ask-user),其中包含问题 id、提示语、标题、选项与多选标志。 #### Token 影响 @@ -49,10 +129,25 @@ #### KV Cache 影响 -仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 + + + + +这些限制说明该工具何时不合适。它们是当前包约束,不是 UI 积压事项。 - **待处理问题会阻塞工具调用,直至用户作答**:该工具未声明 `timeout-policy` 预算;取消仅沿用当前轮次的 `exec.signal`。 -- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝归属于另一个 agent 的存活子级;该子级必须在最终结果中包含尚未解决的问题或决策。持久谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。 +- **运行时中归属于其他 agent 的 subagent 不能向用户提问**:`ask_user_question` 会以 `DELEGATED_CALLER` 拒绝归属于另一个 agent 的存活子级;该子级必须在最终结果中包含尚未解决的问题或决定。持久谱系不能决定这一边界,因此带有谱系的会话恢复为运行时根后可以正常提问。 - **Native 回答渲染为 JSON 文本**:规范值仍为结构化数据,但模型侧结果使用紧凑 JSON,而非更丰富的内容块词汇。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 0e11850943..067115a5d2 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userQuestions seam", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 0c28660c64..071a9a2048 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -1,14 +1,25 @@ import { describe, expect, it } from 'vitest' import { Context } from '@deepseek-ai/cordis' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRuntime from '@deepseek-ai/dsh-tools' -import UserQuestionService, { type AskUserQuestionRequest } from '@deepseek-ai/dsh-user-questions' +import UserQuestionService, { + type AskUserQuestionAnswer, + type AskUserQuestionRequest, +} from '@deepseek-ai/dsh-user-questions' import * as toolAskUser from '@deepseek-ai/dsh-tool-ask-user' const testToolSignal = new AbortController().signal +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerQuestionAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + interface OptionSchemaShape { properties: { questions: { @@ -78,7 +89,7 @@ describe('ask_user_question tool', () => { it('asks the registered user-questions provider and projects structured answers to text', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm'] }] } @@ -87,7 +98,7 @@ describe('ask_user_question tool', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-1'), + callId: ToolCallId('ask-1'), name: 'ask_user_question', arguments: { questions: [{ @@ -114,7 +125,7 @@ describe('ask_user_question tool', () => { it('passes recommended option labels through without adding schema fields', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'pkg', selected: ['pnpm (Recommended)'] }] } @@ -123,7 +134,7 @@ describe('ask_user_question tool', () => { await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-recommended'), + callId: ToolCallId('ask-recommended'), name: 'ask_user_question', arguments: { questions: [{ @@ -145,7 +156,7 @@ describe('ask_user_question tool', () => { it('projects custom answers and multi-select choices', async () => { const ctx = await setup() - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask() { return { answers: [ @@ -159,7 +170,7 @@ describe('ask_user_question tool', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-multi'), + callId: ToolCallId('ask-multi'), name: 'ask_user_question', arguments: { questions: [ @@ -198,7 +209,7 @@ describe('ask_user_question tool', () => { it('passes the tool abort signal to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -207,7 +218,7 @@ describe('ask_user_question tool', () => { const controller = new AbortController() await ctx.tools.execute({ - callId: CallId('ask-2'), + callId: ToolCallId('ask-2'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, signal: controller.signal, @@ -219,7 +230,7 @@ describe('ask_user_question tool', () => { it('passes optional header and a resumed runtime root through to the user-questions request', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -230,7 +241,7 @@ describe('ask_user_question tool', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-3'), + callId: ToolCallId('ask-3'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', header: 'Confirm', question: 'Continue?' }] }, agent, @@ -245,7 +256,7 @@ describe('ask_user_question tool', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-no-provider'), + callId: ToolCallId('ask-no-provider'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, }) @@ -259,7 +270,7 @@ describe('ask_user_question tool', () => { it('rejects a live runtime-owned agent with a structured DELEGATED_CALLER error', async () => { const ctx = await setup() const seen: AskUserQuestionRequest[] = [] - ctx.userQuestions.registerProvider({ + registerQuestionAnswerer(ctx, { async ask(request) { seen.push(request) return { answers: [{ id: 'continue', selected: ['ok'] }] } @@ -272,7 +283,7 @@ describe('ask_user_question tool', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-delegated'), + callId: ToolCallId('ask-delegated'), name: 'ask_user_question', arguments: { questions: [{ id: 'continue', question: 'Continue?' }] }, agent: child, @@ -294,7 +305,7 @@ describe('ask_user_question tool', () => { const result = await ctx.tools.execute({ signal: testToolSignal, - callId: CallId('ask-empty'), + callId: ToolCallId('ask-empty'), name: 'ask_user_question', arguments: { questions: [] }, }) diff --git a/packages/interaction/user-approval/README.i18n.yaml b/packages/interaction/user-approval/README.i18n.yaml index 07843b5f58..1f5b45ddb0 100644 --- a/packages/interaction/user-approval/README.i18n.yaml +++ b/packages/interaction/user-approval/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/interaction/user-approval/README.md -README.md: 0cf5d458863194e29f8c84168a6f089baabbf3d2 -README.zh.md: ccd8641e30d8a78611eabf602fa46d2705f24487 +README.md: 25e6fc2f0464c2019d0dd0c9eba0eef10412455d +README.zh.md: d4801e313f1864d591931bc965f058df6ad840c2 diff --git a/packages/interaction/user-approval/README.md b/packages/interaction/user-approval/README.md index 0cf5d45886..25e6fc2f04 100644 --- a/packages/interaction/user-approval/README.md +++ b/packages/interaction/user-approval/README.md @@ -1,17 +1,107 @@ +--- +description: "Channel-neutral one-shot approval seam for users and maintainers composing answerers, setting policy, or debugging fail-closed permission decisions." +kind: "package-reference" +--- + # @deepseek-ai/dsh-user-approval English | [中文](README.zh.md) -Channel-neutral one-shot approval seam. `ctx.approval.request(req)` returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`; missing or failing answerers fail closed, and a grant applies only to the requested action. Exact event signatures live in the generated region of [approval.md](../../../docs/subsystems/approval.md#cordis-surface). +## Summary -Each request must belong to an open agent turn. The service appends a paired `approval/asked` and `approval/decided` audit record, while the model sees only the resulting logged tool outcome. An aborted request resolves `cancelled`; an audit append that fails before commit rejects rather than returning an unlogged decision. +`dsh-user-approval` lets a sensitive tool action pause for a one-shot allow/reject decision: `ctx.approval.request(req)` asks the composed answerers whether one specific action may proceed and returns `allowed-once`, `rejected`, `cancelled`, or `unavailable`. Missing, non-owning, or throwing answerers fail closed to `unavailable`, and a grant applies only to the requested action. A per-session policy — `ask` (the default) or `never` — decides what happens before any answerer runs: `ask` delegates to the composed answerers, `never` rejects every request deterministically without prompting anyone. Each request is recorded in the requesting session's audit log, and the model sees only the asking consumer's tool outcome plus the current policy in the runtime-context snapshot. UI channels provide human answerers; the ACP automation bridge answers for its own agents. -Answerers are `approval/request` waterfall listeners. Return an outcome to answer for an owned agent or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests; compose one terminal answerer per deployment because sibling listener order is not a policy priority mechanism. The ACP automation bridge supplies one-shot machine decisions for sessions it owns. +## Table of Contents -`ApprovalPolicy` is `'ask'` or `'never'`. The effective value is the last `approval/policy` event, falling back to config; `setApprovalPolicy()` is the write path. `'never'` rejects before interactive dispatch. Both policies contribute their complete current meaning to the cache-safe runtime-context snapshot. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -The tools pipeline routes `ask` decisions through this seam and fails closed when it is absent; the sandboxed bash tool also uses it for escalated retries. The ACP automation bridge answers calls for its own agents through the client's machine policy. Audit events remain log-only, so the model sees only the asking consumer's result. See the [approval-seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) and [sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md). +----- + +## Use this package + +Compose this service when sensitive tool actions should pause for a human or machine decision instead of running unconditionally. The tools pipeline and the sandboxed bash tool route their `ask` decisions through this seam and fail closed when it is absent, so interactive deployments mount it with at least one answerer. + +### Composing answerers + +Answerers are `approval/request` waterfall listeners: return an outcome to answer for an owned agent, or call `next()` to delegate. Agent-scoped listeners receive only that agent's requests, and a deployment composes one terminal answerer — sibling listener order is not a policy-priority mechanism. Without a terminal answerer, requests resolve `unavailable` and fail closed; the service itself never prompts a human. + +### Setting the policy + +The effective policy is the one set for the session, falling back to the configured default. `ask` (the default) delegates to the composed answerers; `never` rejects every request deterministically before interactive dispatch — the strict headless stance for CI and unattended runs. + +```yaml +- name: '@deepseek-ai/dsh-user-approval' + config: + policy: ask +``` + +| Field | Default | Meaning | +|---|---|---| +| `policy` | `ask` | Default for sessions without an `approval/policy` override | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-user-approval) is the exhaustive source for every accepted field and its JSDoc. `setPolicy(agent, policy)` switches a live agent and queues a "changed by the user" message for its next model step; `setApprovalPolicy(session, policy)` is the direct durable write path used by session initialization. + +### Requesting a decision + +`request(req)` names the agent, tool, optional call id and reason, and an abort signal. It requires an open turn: an idle or between-turn caller throws before auditing anything. Aborting withdraws the question — the request settles `cancelled` and a late answer is discarded. A failure that prevents either audit append from committing rejects instead of returning an unlogged decision. + +### What the model and user see + +The model sees only the asking consumer's eventual tool outcome — allowed, rejected, cancelled, or unavailable — plus the current policy in the runtime-context snapshot; the audit events and the human permission UI are not model context. A `never` switch is announced to the model by a sourced user message, and both policies contribute their complete current meaning to the snapshot. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +The observable behavior is covered in [Use this package](#use-this-package); this section explains dispatch, policy enforcement, and the audit path. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | `ApprovalService`: request dispatch, policy fold and write path, runtime-context contribution | +| [`src/types.ts`](src/types.ts) | `ApprovalRequestId` brand and outcome types | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion pairing `approval/asked` with `approval/decided` inside an open turn | + +### Dispatch + +`decide()` races the answerer waterfall against the request signal and contains every answerer failure: a throwing listener fails the question closed to `unavailable`, and a rogue non-vocabulary return is normalized to `unavailable`. The `never` policy is enforced inside the service before waterfall dispatch, so a listener registered later with `prepend` cannot bypass the deterministic rejection. The request must be turn-enclosed because the turn is the durable log's commit/replay boundary — a bare event between turns is indistinguishable from a crash tail. + +### Policy and the runtime-context snapshot + +The system-prompt contribution `approval:policy` states the complete current meaning of the effective policy — `ask` with its fail-closed consequence, or `never` with its non-escalation consequence — after retained history, so switching policy appends a new full snapshot instead of rewriting the stable request header. `setPolicy()` also injects a sourced user message announcing the change for the next step. + +### Audit + +`request()` appends `approval/asked` with the request identity and tool, then `approval/decided` with the closed outcome; the exact appended fields live in [`src/index.ts`](src/index.ts). Both are log-only; the invariant validates the pair by id within one open turn and the closed outcome vocabulary. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the approval vocabulary to the consumers and the design rationale. + +- [Approval subsystem reference](../../../docs/subsystems/approval.md) — the shared request/outcome vocabulary and the `ctx.approval` cordis surface. +- [Approval seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md) — design rationale for the seam. +- [Sandbox Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md) — how the sandboxed bash tool consumes approvals for escalated retries. +- [Interaction group map](../README.md) — adjacent permission preset and question packages. + +----- + + ## Model Experience ### Current approval policy context @@ -56,7 +146,22 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work + + + +These limits define when the seam is a poor fit or needs special composition care. They are current package constraints, not a general permission comparison. + - **Requests are valid only inside an open turn** — an idle or between-turn caller throws before auditing; a durable out-of-turn approval workflow is deferred. - **Only one-shot grants exist** — the outcome vocabulary has `allowed-once` but no `allow-always`, remembered rule, revocation, or grant store; session policy is only `ask` / `never`. - **The request carries no tool arguments** — an answerer sees the tool name, reason, and optional call id; the ACP machine channel requires a call id and delegates requests without one. - **No built-in answerer** — headless or incompletely composed deployments resolve `unavailable` and fail closed; the service itself never prompts a human. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/interaction/user-approval/README.zh.md b/packages/interaction/user-approval/README.zh.md index ccd8641e30..d4801e313f 100644 --- a/packages/interaction/user-approval/README.zh.md +++ b/packages/interaction/user-approval/README.zh.md @@ -1,24 +1,114 @@ +--- +description: "与通道无关的一次性审批 seam;供组合应答者、设置策略或排查以拒绝方式关闭的权限决定的用户与维护者阅读。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-user-approval [English](README.md) | 中文 -与通道无关的一次性审批 seam。`ctx.approval.request(req)` 返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`;应答者缺失或失败时会以拒绝方式关闭,授权也只适用于所请求的操作。确切事件签名见 [approval.md](../../../docs/subsystems/approval.md#cordis-surface) 的生成区块。 +## 概述 -每个请求都必须属于一个尚未结束的 agent(智能体)轮次。服务会追加一对 `approval/asked` 与 `approval/decided` 审计记录,而模型只会看到由此产生且已写入日志的工具结果。已中止的请求会解析为 `cancelled`;如果审计记录的追加在提交前失败,Promise 会被拒绝,而不会返回一项未记录的决定。 +`dsh-user-approval` 让敏感的工具操作暂停等待一次性的允许/拒绝决定:`ctx.approval.request(req)` 向已组合的应答者询问某个具体操作是否可以继续,并返回 `allowed-once`、`rejected`、`cancelled` 或 `unavailable`。应答者缺失、不负责或抛出异常时,请求以 `unavailable` 关闭;授权也只适用于所请求的操作。按会话策略——`ask`(默认)或 `never`——决定在任何应答者运行之前发生什么:`ask` 委托给已组合的应答者,`never` 确定性地拒绝每个请求,不提示任何人。每个请求都会记录在发起请求的会话审计日志中;模型只会看到发起请求的消费方的工具结果,以及运行时上下文快照中的当前策略。UI 通道提供人类应答者;ACP(Agent Client Protocol)自动化桥接层为其自有 agent 作答。 -应答者是 `approval/request` waterfall(瀑布式事件)监听器。要回答其负责的 agent 请求,请返回一个结果;否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求;每项部署应当组合一个最终应答者,因为同级监听器的顺序不是策略优先级机制。ACP(Agent Client Protocol)自动化桥接层为其负责的会话提供一次性机器决定。 +## 目录 -`ApprovalPolicy` 为 `'ask'` 或 `'never'`。实际值取最后一条 `approval/policy` 事件,并回退到配置;`setApprovalPolicy()` 是写入路径。`'never'` 会在交互式分发之前拒绝请求。两种策略都会将各自完整的当前含义贡献给缓存安全的运行时上下文快照。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -工具流水线通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭;沙箱 bash 工具也会将它用于升权重试。ACP 自动化桥接层根据客户端的机器策略,回答其自有 agent 的调用。审计事件仍只写入日志,因此模型只会看到发起请求的消费方所返回的结果。详见[审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.md)和[沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.md)。 +----- + +## 使用本包 + +当敏感工具操作应当暂停等待人或机器的决定、而非无条件执行时,组合此服务。工具流水线与沙箱 bash 工具会通过此 seam 路由 `ask` 决定,并在该 seam 缺失时以拒绝方式关闭,因此交互式部署至少应组合一个应答者。 + +### 组合应答者 + +应答者是 `approval/request` waterfall(瀑布式事件)监听器:返回一个结果即为所负责的 agent 作答,否则调用 `next()` 委托。限定到 agent 的监听器只接收该 agent 的请求,且每项部署应组合一个最终应答者——同级监听器的顺序不是策略优先级机制。没有最终应答者时,请求解析为 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。 + +### 设置策略 + +有效策略取会话中已设置的策略,并回退到配置的默认值。`ask`(默认)委托给已组合的应答者;`never` 在交互式分发之前确定性地拒绝每个请求——这是 CI 与无人值守运行的严格无头姿态。 + +```yaml +- name: '@deepseek-ai/dsh-user-approval' + config: + policy: ask +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `policy` | `ask` | 没有 `approval/policy` 覆盖的会话的默认策略 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-user-approval)是每个受支持字段及其 JSDoc 的穷尽式真源。`setPolicy(agent, policy)` 切换存活 agent 的策略,并为它的下一个模型步骤排队一条「由用户更改」消息;`setApprovalPolicy(session, policy)` 是会话初始化使用的直接持久写入路径。 + +### 请求决定 + +`request(req)` 指名 agent、工具、可选的调用 id 与原因,以及一个中止信号。它要求当前处于尚未结束的轮次中:空闲或在轮次之间调用会在审计前抛出异常。中止会撤回问题——请求以 `cancelled` 结算,迟到的回答被丢弃。若任一审计事件在提交前失败,请求会被拒绝,而不会返回一项未记录的决定。 + +### 模型与用户看到什么 + +模型只会看到发起请求的消费方最终给出的工具结果——允许、拒绝、取消或不可用——以及运行时上下文快照中的当前策略;审计事件与面向人类的权限 UI 不属于模型上下文。`never` 切换会以一条带来源的用户消息告知模型,两种策略都会把各自的完整当前含义贡献给快照。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +可观察行为已在[使用本包](#use-this-package)中说明;本节解释分发、策略执行与审计路径。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | `ApprovalService`:请求分发、策略折叠与写入路径、运行时上下文贡献 | +| [`src/types.ts`](src/types.ts) | `ApprovalRequestId` brand 与结果类型 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:在未结束的轮次内配对 `approval/asked` 与 `approval/decided` | + +### 分发 + +`decide()` 让应答者 waterfall 与请求信号赛跑,并包含所有应答者失败:抛出异常的监听器让问题以 `unavailable` 关闭,不合词汇的返回值被规范化为 `unavailable`。`never` 策略在服务内部、waterfall 分发之前执行,因此之后以 `prepend` 注册的监听器也无法绕过确定性的拒绝。请求必须处于未结束的轮次内,因为轮次是持久日志的提交/回放边界——轮次之间的裸事件与崩溃尾部无法区分。 + +### 策略与运行时上下文快照 + +系统提示词贡献 `approval:policy` 在保留历史之后陈述有效策略的完整当前含义——`ask` 及其关闭后果,或 `never` 及其非升权后果——因此切换策略会追加一份新的完整快照,而不会改写稳定的请求头。`setPolicy()` 还会注入一条带来源的用户消息,为下一步宣布变更。 + +### 审计 + +`request()` 先追加携带请求身份与工具的 `approval/asked`,再追加携带封闭结果的 `approval/decided`;确切追加字段见 [`src/index.ts`](src/index.ts)。两者都只写入日志;不变式在同一个未结束轮次内按 id 校验这一事件对与封闭的结果词汇。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从审批词汇逐步进入消费方与设计依据。 + +- [审批子系统参考](../../../docs/subsystems/approval.zh.md)——共享的请求/结果词汇与 `ctx.approval` 的 cordis 接口面。 +- [审批 seam Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md)——该 seam 的设计依据。 +- [沙箱 Agent Note](../../../.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md)——沙箱 bash 工具如何为升权重试消费审批。 +- [交互组映射](../README.zh.md)——相邻的权限预设与问答包。 + +----- + + ## 模型体验 ### 当前审批策略上下文 #### 模型看到的内容 -首次请求和有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,审批上下文内容会说明系统可以咨询已配置的应答者,缺少可用应答者时则以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。 +首次请求与有效策略每次变化时,都会在保留的历史后追加一份完整运行时上下文快照。在 `ask` 下,审批上下文内容会说明系统可以咨询已配置的应答者,缺少可用应答者时则以拒绝方式关闭。在 `never` 下,它会说明确定性的拒绝与非升权后果。未变化的请求会保留先前快照,不增加另一条消息。 ##### Ask 策略贡献 @@ -54,9 +144,24 @@ Approval prompts are disabled in this session: actions that require approval are 仅追加;新出现的可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **请求只在尚未结束的轮次内有效**:在空闲时或轮次之间发起调用,会在审计前抛出异常;持久化的轮次外审批工作流仍属暂缓事项。 + + + +这些限制说明该 seam 何时不合适,或何时需要特别的组合注意。它们是当前包约束,不是通用权限对比。 + +- **请求只在尚未结束的轮次内有效**:在空闲时或轮次之间发起调用,会在审计前抛出异常;持久化的轮次外审批工作流仍属延期工作。 - **仅存在一次性授权**:结果词汇包含 `allowed-once`,但不含 `allow-always`、已记住的规则、撤销或授权存储;会话策略只有 `ask`/`never`。 - **请求不携带工具参数**:应答者会看到工具名称、原因和可选调用 id;ACP 机器通道要求调用 id,并会委托不含 id 的请求。 - **没有内置应答者**:无头或组合不完整的部署会返回 `unavailable` 并以拒绝方式关闭;服务自身绝不会提示人类。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 06f72c1e2f..5ec59a32d6 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index b0618f6b3d..2608643ca0 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -8,9 +8,8 @@ import { randomUUID } from 'node:crypto' import { Context, Service } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' -import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' +import { createUserMessage, type ToolCallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' -import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' @@ -18,44 +17,10 @@ declare module '@deepseek-ai/cordis' { interface Context { approval: ApprovalService } - - interface Events { - /** - * Ask composed answerers for one decision. Return an outcome to claim the - * request or call `next()`; failure yields the fail-closed default. - * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. - * @param req - the pending decision (agent, tool identity, reason, signal). - * @mode waterfall - */ - 'approval/request'(this: Scoped, req: ApprovalRequest, next: () => Promise): Promise - } } declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { - /** - * An approval question was put to the answerer chain — log-only audit - * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs - * it with the `approval/decided` that always follows; `toolName` is the - * tool the question is about, `callId` the exact tool call when the asker - * had one, `reason` the asker's human-readable explanation (e.g. a hook's - * permission-decision reason). - */ - 'approval/asked': { - id: ApprovalRequestId - toolName: string - callId?: CallId - reason?: string - } - /** - * The outcome of a prior `approval/asked` (same `id`) — log-only audit. - * Exactly one per ask, appended when the outcome is known: a decision, a - * cancellation, or the fail-closed `'unavailable'`. - */ - 'approval/decided': { - id: ApprovalRequestId - outcome: ApprovalOutcome - } /** * The session's approval policy was switched — log-only, durable, * replayable, never in the model transcript (the model learns the policy @@ -73,7 +38,7 @@ declare module '@deepseek-ai/dsh-session/types' { } import { ApprovalRequestId } from './types.ts' -import type { ApprovalOutcome } from './types.ts' +import type { ApprovalOutcome, ApprovalRequestEvent } from './types.ts' export { ApprovalRequestId } from './types.ts' export type { ApprovalOutcome } from './types.ts' @@ -150,7 +115,7 @@ export function setApprovalPolicy(session: Session, policy: ApprovalPolicy): voi * Readonly same-process permission question. `callId` links to an already * presented tool call, so arguments are not duplicated here. */ -export interface ApprovalRequest { +export interface ApprovalRequest extends ApprovalRequestEvent { /** * The agent on whose behalf the question is asked. Routes the question (a * UI answerer only answers for agents it owns) and receives the audit @@ -163,7 +128,7 @@ export interface ApprovalRequest { * The exact tool call being decided, when the asker has one — lets a UI * attach the prompt to the tool call it already streamed. */ - readonly callId?: CallId + readonly callId?: ToolCallId /** The asker's human-readable explanation of WHY it is asking. */ readonly reason?: string /** @@ -316,7 +281,7 @@ export class ApprovalService extends Service { // the containment into the caller. const answer: Promise = Promise.resolve().then( () => this.ctx.waterfall( - scopeTarget(this, req.agent), 'approval/request', req, + scopeTarget(req.agent, req.agent), 'approval/request', req, () => Promise.resolve('unavailable'), ), ).then( diff --git a/packages/interaction/user-approval/src/types.ts b/packages/interaction/user-approval/src/types.ts index 5a862ea546..862d1739f0 100644 --- a/packages/interaction/user-approval/src/types.ts +++ b/packages/interaction/user-approval/src/types.ts @@ -6,6 +6,9 @@ */ import type { Branded } from '@deepseek-ai/dsh-brand' +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent/types' +import type { ToolCallId } from '@deepseek-ai/dsh-llm/brand' /** * Pairs one `approval/asked` audit event with its `approval/decided`. @@ -27,3 +30,62 @@ export function ApprovalRequestId(id: string): ApprovalRequestId { * request, or unavailable answerer. Callers fail closed on `unavailable`. */ export type ApprovalOutcome = 'allowed-once' | 'rejected' | 'cancelled' | 'unavailable' + +declare module '@deepseek-ai/dsh-session/types' { + interface SessionEventMap { + /** + * An approval question was put to the answerer chain — log-only audit + * (like `hook/*`; NOT a surface event, carries no `surfaceOp`). `id` pairs + * it with the `approval/decided` that always follows; `toolName` is the + * tool the question is about, `callId` the exact tool call when the asker + * had one, `reason` the asker's human-readable explanation (e.g. a hook's + * permission-decision reason). + */ + 'approval/asked': { + id: ApprovalRequestId + toolName: string + callId?: ToolCallId + reason?: string + } + /** + * The outcome of a prior `approval/asked` (same `id`) — log-only audit. + * Exactly one per ask, appended when the outcome is known: a decision, a + * cancellation, or the fail-closed `'unavailable'`. + */ + 'approval/decided': { + id: ApprovalRequestId + outcome: ApprovalOutcome + } + } +} + +/** Client-safe payload declared for the approval answerer waterfall. */ +export interface ApprovalRequestEvent { + /** Agent identity projected to the corresponding Client Context in transit. */ + readonly agent: Agent + /** Tool whose operation requires a decision. */ + readonly toolName: string + /** Exact tool call being decided, when available. */ + readonly callId?: ToolCallId + /** Human-readable reason supplied by the asker. */ + readonly reason?: string + /** Cancellation lifetime of the pending request. */ + readonly signal?: AbortSignal +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Ask composed answerers for one decision. Return an outcome to claim the + * request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param req - pending approval request. + * @mode waterfall + */ + 'approval/request'( + this: Scoped, + req: ApprovalRequestEvent, + next: () => Promise, + ): Promise + } +} diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 3271ecc7a5..eb0484a1db 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import { CallId } from '@deepseek-ai/dsh-llm' +import { ToolCallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' @@ -60,7 +60,7 @@ describe('ApprovalService.request', () => { const ctx = await mounted() const { agent, appended } = fakeAgent() - const outcome = await ctx.approval.request(requestOf(agent, { callId: CallId('call-1'), reason: 'hook says ask' })) + const outcome = await ctx.approval.request(requestOf(agent, { callId: ToolCallId('call-1'), reason: 'hook says ask' })) expect(outcome).toBe('unavailable') expect(appended.map(e => e.type)).toEqual(['approval/asked', 'approval/decided']) @@ -95,7 +95,7 @@ describe('ApprovalService.request', () => { }) const request = requestOf(agent, { toolName: 'scoped-tool', - callId: CallId('scoped-call'), + callId: ToolCallId('scoped-call'), reason: 'scoped reason', }) diff --git a/packages/interaction/user-questions/README.i18n.yaml b/packages/interaction/user-questions/README.i18n.yaml index bdcbeee898..c82cbdda18 100644 --- a/packages/interaction/user-questions/README.i18n.yaml +++ b/packages/interaction/user-questions/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/interaction/user-questions/README.md -README.md: 53459c39c75d5d3907b002239c83db5f82e024f5 -README.zh.md: 3a1f016aef6efa0a2167523fd4e1a938fa53eb71 +README.md: f5b8c8f6d9d2d376a60d32d096cacf36fede5a7b +README.zh.md: cbabec7f551ab2257e860c45bbd27b99321c6e05 diff --git a/packages/interaction/user-questions/README.md b/packages/interaction/user-questions/README.md index 53459c39c7..f5b8c8f6d9 100644 --- a/packages/interaction/user-questions/README.md +++ b/packages/interaction/user-questions/README.md @@ -1,15 +1,32 @@ +--- +description: "Waterfall-based question and answer service for tools, permission plugins, local answerers, and Agent-scoped Web interactions." +kind: "package-reference" +--- + # @deepseek-ai/dsh-user-questions English | [中文](README.zh.md) -User-interaction Service Definition. It owns `ctx.userQuestions`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision. +## Summary +User-interaction Service Definition. It owns `ctx.userQuestions`, the service a model-facing tool or permission plugin uses when it needs to pause work and ask the human for a decision. Use it when a consumer must suspend an operation until the user answers. + +## Table of Contents + +- [Service: `UserQuestionService` (ctx key: `userQuestions`)](#service-userquestionservice-ctx-key-userquestions) +- [Role](#role) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) + +----- + + ## Service: `UserQuestionService` (ctx key: `userQuestions`) ### Public API -- `ctx.userQuestions.registerProvider(provider): () => void` Register the UI-side provider. Only one provider may be active in a context; disposal unregisters it. -- `ctx.userQuestions.ask(request): Promise` Ask the active provider and wait for the answer. +- `ctx.userQuestions.ask(request): Promise` Dispatch the answerer waterfall and wait for the first accepted answer. ### Key Types @@ -17,24 +34,25 @@ User-interaction Service Definition. It owns `ctx.userQuestions`, the service a - `AskUserQuestionOption` — `{ label, description? }`. - `AskUserQuestionIntent` — `{ kind: 'plan-review', approve }`; the tagged presentation intent below. - `AskUserQuestionAnswer` — `{ answers: [{ id, selected, custom? }] }`. -- `UserQuestionProvider` — UI implementation with `ask(request)`. -- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `DUPLICATE_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`. +- `UserQuestionError` — `HarnessError` subclass with codes such as `EMPTY_QUESTIONS`, `BAD_INTENT`, `NO_PROVIDER`, `ASK_ABORTED`, `CALLER_NOT_LIVE`, and `DELEGATED_CALLER`. For a single-select question, `custom` overrides the selected choice and `selected` is empty. For a multi-select question, `custom` may supplement the labels in `selected`. A UI may preserve a skipped item as `{ id, selected: [] }`, keeping the existing answer shape while retaining other answers in the batch. -When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. Agentless programmatic requests retain the existing provider path. +When a request carries an agent, `ask()` authenticates its exact identity through the live `AgentRegistry` and admits only a runtime root. Durable lineage is not authority: a session with historical delegation depth may ask after it is resumed as a new runtime root, while a live child owned by another agent is rejected even if its durable depth is zero. The Web answerer receives only Agent-scoped requests; an agentless programmatic request remains available to unscoped local waterfall listeners and fails with `NO_PROVIDER` when none accepts it. ### Presentation intent `intent` declares that a question IS a known kind of decision, so a UI that recognises the tag may present it as such — `plan-review` says `detail` is a plan under review, and `dsh-plan-mode` sets it on the `exit_plan_mode` question. An intent changes presentation only: a UI honouring it answers with the same option labels a generic UI would send, and a UI that does not know the tag renders the generic option list, so callers read the same answer fields either way. `approve` names the label that approves rather than relying on option order. `ask()` rejects with `BAD_INTENT` the two assertions no type can carry: an `approve` naming none of that question's own options, and an intent on a question with no `detail` — the thing it declares itself a review of. + ## Role -This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web host runtime supplies the shipped Service Provider. The loop stays unchanged: a tool call awaits a promise, and the tool result resumes the normal agent loop. +This is the Service Definition package. Consumers such as `@deepseek-ai/dsh-tool-ask-user` depend on this service; the Web client contributes an Agent-scoped answerer through Remote Events. The loop stays unchanged: a tool call awaits the waterfall result, and that result resumes the normal agent loop. + ## Model Experience -Indirectly, through `dsh-tool-ask-user`, which retains a successful provider answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions provider is registered`, or `Error: `. Waiting for the human adds no tokens. +Indirectly, through `dsh-tool-ask-user`, which retains a successful answer as compact JSON or one of these failures: `Error: ask_user_question was aborted before the user answered`, `Error: ask_user_question requires at least one question`, `Error: human interaction requires the exact live calling agent when an agent is supplied`, `Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`, `Error: no user-questions answerer accepted the request`, or `Error: `. Waiting for the human adds no tokens. #### KV Cache effect @@ -42,5 +60,18 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **One provider per context** — there is no routing or fan-out to multiple UIs; a second registration throws `DUPLICATE_PROVIDER`, and with none registered `ask()` throws `NO_PROVIDER` rather than degrading. + + +- **Agent-scoped Web answering** — Remote Events route the shipped Web answerer only when the request carries a live Agent scope; agentless callers need an unscoped local waterfall listener. - **The vocabulary is the question-form shape only** — selectable options plus optional custom text; richer interaction shapes (file pickers, diff-preview confirmations) have no seam vocabulary yet. + + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/interaction/user-questions/README.zh.md b/packages/interaction/user-questions/README.zh.md index 3a1f016aef..cbabec7f55 100644 --- a/packages/interaction/user-questions/README.zh.md +++ b/packages/interaction/user-questions/README.zh.md @@ -1,15 +1,32 @@ +--- +description: "基于 waterfall 的问答服务,用于工具、权限插件、本地 answerer 与 Agent-scoped Web 交互。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-user-questions [English](README.md) | 中文 -用户交互 Service Definition。它定义 `ctx.userQuestions`,供面向模型的工具或权限插件在需要暂停工作并询问人类决定时使用。 +## 概述 +用户交互 Service Definition。它定义 `ctx.userQuestions`,供面向模型的工具或权限插件在需要暂停工作并询问人类决定时使用。当消费方必须暂停操作并等待用户回答时,请使用它。 + +## 目录 + +- [服务:`UserQuestionService`(ctx 键:`userQuestions`)](#service-userquestionservice-ctx-key-userquestions) +- [职责](#role) +- [模型体验](#model-experience) +- [已知限制与暂缓事项](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) + +----- + + ## 服务:`UserQuestionService`(ctx 键:`userQuestions`) ### 公开 API -- `ctx.userQuestions.registerProvider(provider): () => void` 注册 UI 侧提供方。同一上下文中只能有一个活跃提供方;dispose(资源释放)会将其注销。 -- `ctx.userQuestions.ask(request): Promise` 向活跃提供方提问并等待回答。 +- `ctx.userQuestions.ask(request): Promise` 派发回答者 waterfall,并等待第一个接受请求的回答。 ### 关键类型 @@ -17,24 +34,25 @@ - `AskUserQuestionOption`:`{ label, description? }`。 - `AskUserQuestionIntent`:`{ kind: 'plan-review', approve }`;即下文的带标签呈现意图。 - `AskUserQuestionAnswer`:`{ answers: [{ id, selected, custom? }] }`。 -- `UserQuestionProvider`:包含 `ask(request)` 的 UI 实现。 -- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`DUPLICATE_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。 +- `UserQuestionError`:`HarnessError` 的子类,包含 `EMPTY_QUESTIONS`、`BAD_INTENT`、`NO_PROVIDER`、`ASK_ABORTED`、`CALLER_NOT_LIVE` 和 `DELEGATED_CALLER` 等代码。 对于单选题,`custom` 会覆盖选中的选项,且 `selected` 为空。对于多选题,`custom` 可以补充 `selected` 中的标签。UI 可以把跳过的条目保留为 `{ id, selected: [] }`,既维持现有回答形态,也保留该批次中的其他回答。 -请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。不含 agent 的程序化请求继续沿用现有提供方路径。 +请求包含 agent 时,`ask()` 会通过当前 `AgentRegistry` 验证该 agent 与注册表中的存活实例是同一对象,并且只允许运行时根调用。持久谱系不构成权限依据:带有历史委托深度的会话恢复为新的运行时根后可以提问;归属于另一个 agent 的存活子级即使持久化记录的委托深度为零也会被拒绝。Web 回答者只接收带 Agent scope 的请求;不含 agent 的程序化请求仍会交给本地未限定 scope 的 waterfall listener,若无人接受则以 `NO_PROVIDER` 失败。 ### 呈现意图 `intent` 声明某个问题本身就是一种已知决策,因此认识该标签的 UI 可以照此呈现——`plan-review` 表示 `detail` 是一份待审阅的计划,`dsh-plan-mode` 会在 `exit_plan_mode` 的问题上设置它。意图只改变呈现:遵循它的 UI 回答的仍是通用 UI 会发送的那些选项标签,不认识该标签的 UI 渲染通用选项列表,因此调用方两种情况下读到的回答字段相同。`approve` 指名表示批准的标签,而不依赖选项顺序。有两项断言无法通过类型表达,`ask()` 会以 `BAD_INTENT` 拒绝它们:`approve` 未命中该问题自身的任一选项,以及意图落在没有 `detail` 的问题上——而 `detail` 正是它自称在审阅的东西。 + ## 职责 -这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web 宿主运行时提供随产品交付的 Service Provider。循环保持不变:工具调用等待 Promise,工具结果随后恢复正常的 agent loop(智能体循环)。 +这是 Service Definition 包。`@deepseek-ai/dsh-tool-ask-user` 等 Consumer 依赖此服务;Web Client 通过 Remote Events 贡献带 Agent scope 的回答者。循环保持不变:工具调用等待 waterfall 结果,该结果随后恢复正常的 agent loop(智能体循环)。 + ## 模型体验 -间接地,通过 `dsh-tool-ask-user`:它会将成功的提供方回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`、`Error: no user-questions provider is registered` 或 `Error: `。等待人类回答不会增加 token。 +间接地,通过 `dsh-tool-ask-user`:它会将成功回答保留为紧凑 JSON,或返回以下失败之一:`Error: ask_user_question was aborted before the user answered`、`Error: ask_user_question requires at least one question`、`Error: human interaction requires the exact live calling agent when an agent is supplied`、`Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result`、`Error: no user-questions answerer accepted the request` 或 `Error: `。等待人类回答不会增加 token。 #### KV Cache 影响 @@ -42,5 +60,18 @@ ## 已知限制与暂缓事项 -- **每个上下文只能有一个提供方**:不支持路由或扇出到多个 UI;第二次注册会抛出 `DUPLICATE_PROVIDER`,未注册任何提供方时,`ask()` 会抛出 `NO_PROVIDER`,而不会降级。 + + +- **带 Agent scope 的 Web 回答**:Remote Events 仅在请求带有存活 Agent scope 时路由随产品交付的 Web 回答者;agentless 调用方需要本地未限定 scope 的 waterfall listener。 - **词汇仅包含问题表单形态**:可供选择的选项加可选的自定义文本;更丰富的交互形态(文件选择器、diff 预览确认)尚无 seam 词汇。 + + + +### 开发备注 + +
+维护者工作上下文——点击展开 + +无。 + +
diff --git a/packages/interaction/user-questions/package.json b/packages/interaction/user-questions/package.json index 30709a00f3..21b01aeae3 100644 --- a/packages/interaction/user-questions/package.json +++ b/packages/interaction/user-questions/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-questions", "description": "Abstract user-questions seam (ctx.userQuestions) for asking the human during agent runs", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, @@ -40,12 +40,14 @@ "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-questions/src/index.ts b/packages/interaction/user-questions/src/index.ts index 0862f49cd0..1d5d136426 100644 --- a/packages/interaction/user-questions/src/index.ts +++ b/packages/interaction/user-questions/src/index.ts @@ -1,15 +1,16 @@ /** * Service Definition for the user-questions capability seam (`ctx.userQuestions`): a UI-backed service for * pausing an agent tool call until the human answers a question. The model- - * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages provide - * the single active provider. + * facing tool lives in `@deepseek-ai/dsh-tool-ask-user`; UI packages compose + * answerers on the Agent-scoped Cordis waterfall. * * @module @deepseek-ai/dsh-user-questions */ import { Context, Service } from '@deepseek-ai/cordis' -import type { Agent } from '@deepseek-ai/dsh-agent' +import type {} from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' +import { scopeTarget } from '@deepseek-ai/dsh-scope' declare module '@deepseek-ai/cordis' { interface Context { @@ -17,7 +18,9 @@ declare module '@deepseek-ai/cordis' { } } -import type { AskUserQuestionAnswer, AskUserQuestionItem } from './types.ts' +import type { + AskUserQuestionAnswer, AskUserQuestionRequestEvent, +} from './types.ts' export type { AskUserQuestionAnswer, AskUserQuestionAnswerItem, AskUserQuestionIntent, AskUserQuestionItem, @@ -25,19 +28,7 @@ export type { } from './types.ts' /** Request for a human answer. */ -export interface AskUserQuestionRequest { - /** Questions to display. */ - questions: AskUserQuestionItem[] - /** Exact live calling agent, when the request came from an agent tool call. */ - agent?: Agent - /** Abort signal for the owning tool/step. */ - signal?: AbortSignal -} - -/** UI-side provider for user questions. */ -export interface UserQuestionProvider { - ask(request: AskUserQuestionRequest): Promise -} +export interface AskUserQuestionRequest extends AskUserQuestionRequestEvent {} /** Stable error taxonomy for user-questions failures. */ export class UserQuestionError extends HarnessError { @@ -47,35 +38,37 @@ export class UserQuestionError extends HarnessError { } } -/** `ctx.userQuestions`: one active UI provider plus an `ask()` API. */ -export class UserQuestionService extends Service { - private provider: UserQuestionProvider | undefined +function abortedQuestion(cause?: unknown): UserQuestionError { + return new UserQuestionError( + 'ask_user_question was aborted before the user answered', + 'ASK_ABORTED', + cause === undefined ? undefined : { cause }, + ) +} +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function restoreUserQuestionError(reason: unknown): unknown { + if (reason instanceof UserQuestionError) return reason + if (isRecord(reason) + && reason.name === 'UserQuestionError' + && typeof reason.message === 'string' + && typeof reason.code === 'string') { + return new UserQuestionError(reason.message, reason.code, { cause: reason }) + } + return reason +} + +/** `ctx.userQuestions`: validation plus the scoped answerer waterfall. */ +export class UserQuestionService extends Service { constructor(ctx: Context) { super(ctx, 'userQuestions') } /** - * Register the UI provider. Only one provider may be active in a context. - * - * @param provider UI-side implementation that collects answers. - * @returns Disposer that unregisters this provider. - */ - registerProvider(provider: UserQuestionProvider): () => void { - const dispose = this.ctx.effect(function* (this: UserQuestionService) { - if (this.provider !== undefined) { - throw new UserQuestionError('a user-questions provider is already registered', 'DUPLICATE_PROVIDER') - } - this.provider = provider - yield () => { - this.provider = undefined - } - }.bind(this), 'userInteraction.registerProvider()') - return () => void dispose() - } - - /** - * Ask the active UI provider and wait for the user's answer. + * Ask the scoped answerer waterfall and wait for the user's answer. * * When a caller supplies an agent, human interaction is valid only for the * exact live runtime root. Runtime ownership, not durable session lineage, @@ -85,13 +78,14 @@ export class UserQuestionService extends Service { * * @param request Questions, owner agent, and abort signal. * @returns The answer chosen or typed by the human. - * @throws {UserQuestionError} code `CALLER_NOT_LIVE` when a supplied - * agent is not the registry's exact live instance, or `DELEGATED_CALLER` - * when that live agent is owned by another agent. + * @throws {UserQuestionError} code `ASK_ABORTED` when the supplied signal + * is already or becomes aborted, `CALLER_NOT_LIVE` when a supplied agent + * is not the registry's exact live instance, or `DELEGATED_CALLER` when + * that live agent is owned by another agent. */ async ask(request: AskUserQuestionRequest): Promise { if (request.signal?.aborted) { - throw new UserQuestionError('ask_user_question was aborted before the user answered', 'ASK_ABORTED') + throw abortedQuestion() } if (request.questions.length === 0) { throw new UserQuestionError('ask_user_question requires at least one question', 'EMPTY_QUESTIONS') @@ -133,10 +127,27 @@ export class UserQuestionService extends Service { 'BAD_INTENT') } } - if (this.provider === undefined) { - throw new UserQuestionError('no user-questions provider is registered', 'NO_PROVIDER') + const noAnswerer = () => Promise.reject(new UserQuestionError( + 'no user-questions answerer accepted the request', + 'NO_PROVIDER', + )) + try { + return await (agent === undefined + ? this.ctx.waterfall('user-questions/request', request, noAnswerer) + : this.ctx.waterfall( + scopeTarget(agent, agent), + 'user-questions/request', + { ...request, agent }, + noAnswerer, + )) + } catch (error) { + const restored = restoreUserQuestionError(error) + if (restored instanceof UserQuestionError) throw restored + if (request.signal?.aborted) { + throw abortedQuestion(error) + } + throw restored } - return this.provider.ask(request) } } diff --git a/packages/interaction/user-questions/src/types.ts b/packages/interaction/user-questions/src/types.ts index 147d416f65..1818a63d91 100644 --- a/packages/interaction/user-questions/src/types.ts +++ b/packages/interaction/user-questions/src/types.ts @@ -1,9 +1,7 @@ -/** - * Wire-safe question and answer types, free of cordis/service imports so browser - * type chains (apiproxy api → client) can consume them without loading this - * package's Context augmentation. - * @module @deepseek-ai/dsh-user-questions/types - */ +/** Client-safe question, answer, and event types. @module @deepseek-ai/dsh-user-questions/types */ + +import type { Scoped } from '@deepseek-ai/dsh-scope' +import type { Agent } from '@deepseek-ai/dsh-agent/types' /** One selectable answer offered to the user. */ export interface AskUserQuestionOption { @@ -64,3 +62,30 @@ export interface AskUserQuestionAnswer { /** Structured answers keyed by question id. */ answers: AskUserQuestionAnswerItem[] } + +/** Client-safe payload declared for the user-question answerer waterfall. */ +export interface AskUserQuestionRequestEvent { + /** Questions to display. */ + questions: AskUserQuestionItem[] + /** Agent identity projected to the corresponding Client Context in transit. */ + agent?: Agent + /** Cancellation lifetime of the pending request. */ + signal?: AbortSignal +} + +declare module '@deepseek-ai/cordis' { + interface Events { + /** + * Ask composed answerers for structured user input. Return an answer to + * claim the request or call `next()` to delegate. Scope-filtered dispatch + * (`@deepseek-ai/dsh-scope`): agent-scoped listeners receive only that agent. + * @param request - pending user-question request. + * @mode waterfall + */ + 'user-questions/request'( + this: Scoped, + request: AskUserQuestionRequestEvent, + next: () => Promise, + ): Promise + } +} diff --git a/packages/interaction/user-questions/tests/user-questions.spec.ts b/packages/interaction/user-questions/tests/user-questions.spec.ts index 4c6c153a4a..02ee19ea26 100644 --- a/packages/interaction/user-questions/tests/user-questions.spec.ts +++ b/packages/interaction/user-questions/tests/user-questions.spec.ts @@ -3,17 +3,27 @@ import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import UserQuestionService, { UserQuestionError, + type AskUserQuestionAnswer, type AskUserQuestionRequest, - type UserQuestionProvider, } from '@deepseek-ai/dsh-user-questions' -function provider(answer = 'approved'): UserQuestionProvider & { seen: AskUserQuestionRequest[] } { +interface QuestionAnswerer { + ask(request: AskUserQuestionRequest): Promise +} + +function registerAnswerer(ctx: Context, answerer: QuestionAnswerer): () => void { + return ctx.on('user-questions/request', request => answerer.ask(request)) +} + +function provider(answer = 'approved'): QuestionAnswerer & { seen: AskUserQuestionRequest[] } { const seen: AskUserQuestionRequest[] = [] return { seen, async ask(request) { seen.push(request) - return { answers: [{ id: request.questions[0]?.id ?? 'missing', selected: [answer] }] } + return { + answers: request.questions.map(question => ({ id: question.id, selected: [answer] })), + } }, } } @@ -31,12 +41,13 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider('yes') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) + const questions = [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }] - const result = await ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }] }) + const result = await ctx.userQuestions.ask({ questions }) expect(result).toEqual({ answers: [{ id: 'confirm', selected: ['yes'] }] }) - expect(p.seen).toEqual([{ questions: [{ id: 'confirm', question: 'Proceed?' }] }]) + expect(p.seen).toEqual([{ questions }]) }) it('rejects ask requests when no provider is registered', async () => { @@ -51,7 +62,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider() - const dispose = ctx.userQuestions.registerProvider(p) + const dispose = registerAnswerer(ctx, p) dispose() dispose() @@ -60,20 +71,28 @@ describe('UserQuestionService', () => { .rejects.toMatchObject({ code: 'NO_PROVIDER' }) }) - it('rejects duplicate providers instead of replacing the active UI', async () => { + it('delegates through composed answerers', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) - ctx.userQuestions.registerProvider(provider('first')) + const delegated = vi.fn() + ctx.on('user-questions/request', (_request, next) => { + delegated() + return next() + }) + const p = provider('second') + registerAnswerer(ctx, p) - expect(() => ctx.userQuestions.registerProvider(provider('second'))) - .toThrow(UserQuestionError) + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'second' }] }], + })).resolves.toEqual({ answers: [{ id: 'confirm', selected: ['second'] }] }) + expect(delegated).toHaveBeenCalledOnce() }) it('fails before reaching the provider when the signal is already aborted', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [{ id: 'confirm', selected: ['too late'] }] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const controller = new AbortController() controller.abort() @@ -82,11 +101,91 @@ describe('UserQuestionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('normalizes an in-flight signal cancellation to ASK_ABORTED', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const pending = Promise.withResolvers() + registerAnswerer(ctx, { ask: () => pending.promise }) + const controller = new AbortController() + const abortReason = new DOMException('This operation was aborted', 'AbortError') + + const answer = ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + signal: controller.signal, + }) + controller.abort(abortReason) + pending.reject(abortReason) + + await expect(answer).rejects.toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_ABORTED', + cause: abortReason, + }) + }) + + it('preserves a domain rejection when its provider also aborts the signal', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const controller = new AbortController() + const cancelled = new UserQuestionError('the user cancelled ask_user_question', 'ASK_CANCELLED') + registerAnswerer(ctx, { + ask: () => { + controller.abort() + return Promise.reject(cancelled) + }, + }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + signal: controller.signal, + })).rejects.toBe(cancelled) + }) + + it('restores a transported provider rejection to UserQuestionError', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const transported = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + }) + registerAnswerer(ctx, { ask: () => Promise.reject(transported) }) + + const rejection = await ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + }).then( + () => undefined, + (error: unknown) => error, + ) + + expect(rejection).toBeInstanceOf(UserQuestionError) + expect(rejection).toMatchObject({ + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + cause: transported, + }) + }) + + it.each([ + ['an ordinary Error', new Error('provider failed')], + ['a namesake Error without a string code', Object.assign(new Error('provider failed'), { + name: 'UserQuestionError', + })], + ['a non-Error rejection', { name: 'UserQuestionError', code: 'ASK_CANCELLED' }], + ])('preserves %s from the provider', async (_label, rejection) => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + registerAnswerer(ctx, { ask: vi.fn().mockRejectedValue(rejection) }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + })).rejects.toBe(rejection) + }) + it('rejects empty question batches before reaching the provider', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) await expect(ctx.userQuestions.ask({ questions: [] })) .rejects.toMatchObject({ name: 'UserQuestionError', code: 'EMPTY_QUESTIONS' }) @@ -98,7 +197,7 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const root = stubAgent('root', 0) const child = stubAgent('child', 0) ctx.agents.enter(root, undefined) @@ -120,12 +219,12 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = provider('yes') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const agent = stubAgent('resumed-root', 1) ctx.agents.enter(agent, undefined) const result = await ctx.userQuestions.ask({ - questions: [{ id: 'confirm', question: 'Proceed?' }], + questions: [{ id: 'confirm', question: 'Proceed?', options: [{ label: 'yes' }] }], agent, }) @@ -136,7 +235,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) await expect(ctx.userQuestions.ask({ questions: [{ id: 'confirm', question: 'Proceed?' }], @@ -150,7 +249,7 @@ describe('UserQuestionService', () => { await ctx.plugin(AgentRegistry) await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const live = stubAgent('same-id') ctx.agents.enter(live, undefined) @@ -161,11 +260,41 @@ describe('UserQuestionService', () => { expect(p.ask).not.toHaveBeenCalled() }) + it('restores a transported UserQuestionError to the public error class', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const transported = Object.assign(new Error('the user cancelled ask_user_question'), { + name: 'UserQuestionError', + code: 'ASK_CANCELLED', + }) + registerAnswerer(ctx, { ask: () => Promise.reject(transported) }) + + const failure = await ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + }).then(() => undefined, (error: unknown) => error) + + expect(failure).toBeInstanceOf(UserQuestionError) + expect(failure).toMatchObject({ + name: 'UserQuestionError', code: 'ASK_CANCELLED', cause: transported, + }) + }) + + it('preserves a provider rejection outside the UserQuestionError taxonomy', async () => { + const ctx = new Context() + await ctx.plugin(UserQuestionService) + const failure = new Error('provider failed') + registerAnswerer(ctx, { ask: () => Promise.reject(failure) }) + + await expect(ctx.userQuestions.ask({ + questions: [{ id: 'confirm', question: 'Proceed?' }], + })).rejects.toBe(failure) + }) + it('rejects an intent whose approve label names none of its own options', async () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const question = { id: 'plan-review', question: 'Approve?', detail: '# Plan' } // A wrong label among offered options, and no options offered at all. @@ -185,7 +314,7 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = { ask: vi.fn(async () => ({ answers: [] })) } - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) // Detail IS the plan for this intent, so a UI honouring it would ask the // user to approve something they cannot see. @@ -203,12 +332,12 @@ describe('UserQuestionService', () => { const ctx = new Context() await ctx.plugin(UserQuestionService) const p = provider('Approve') - ctx.userQuestions.registerProvider(p) + registerAnswerer(ctx, p) const intent = { kind: 'plan-review', approve: 'Approve' } as const const result = await ctx.userQuestions.ask({ questions: [ - { id: 'plain', question: 'Proceed?' }, + { id: 'plain', question: 'Proceed?', options: [{ label: 'Approve' }] }, { id: 'plan-review', question: 'Approve?', detail: '# Plan', options: [{ label: 'Approve' }, { label: 'Keep planning' }], intent, @@ -216,7 +345,10 @@ describe('UserQuestionService', () => { ], }) - expect(result.answers).toEqual([{ id: 'plain', selected: ['Approve'] }]) + expect(result.answers).toEqual([ + { id: 'plain', selected: ['Approve'] }, + { id: 'plan-review', selected: ['Approve'] }, + ]) expect(p.seen[0]?.questions[1]?.intent).toEqual(intent) }) }) diff --git a/packages/jobs/README.i18n.yaml b/packages/jobs/README.i18n.yaml index 6c032dcbf0..2aa06400aa 100644 --- a/packages/jobs/README.i18n.yaml +++ b/packages/jobs/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/jobs/README.md -README.md: daad96ae561f8f943759332b6b1ea1e20fcaf40e -README.zh.md: 4e733b1da09656c9d779c43a0ee77703e1c10bfa +README.md: fd4a18989dc0ae7af6e5cea128bf1988e3416c78 +README.zh.md: 6f75cb3fc463171a783a73b367804a6ca90eae98 diff --git a/packages/jobs/README.md b/packages/jobs/README.md index daad96ae56..fd4a18989d 100644 --- a/packages/jobs/README.md +++ b/packages/jobs/README.md @@ -1,15 +1,50 @@ +--- +description: "The jobs group map: background-job control — the registry contract, process-local storage, and the model-facing job tools — for users and maintainers navigating the group." +kind: "package-group" +--- + # jobs/ — background-job capability family English | [中文](README.zh.md) -This family gives long-running tools one owner-isolated background-job protocol for observation, cancellation, waiting, and completion notices. +## Summary + +The jobs group is the background-work capability family: tools that run long work register it as a job, and the owning agent can read, wait on, list, and cancel it without blocking its own turn. Jobs belong to the agent session that started them, so one agent never sees another's work, and completion is delivered to the owning agent in-session instead of polled. The group splits into the registry contract (`jobs`), its process-local storage (`jobs-local`), and the model-facing control tools with completion notices (`tool-jobs`). + +## Table of Contents + +- [Packages](#packages) +- [Related documentation](#related-documentation) +- [Dev Note](#dev-note) + +----- + + +## Packages | Package | Role | ctx key | |---|---|---| -| [`jobs/`](jobs/README.md) | Defines the job registry and lifecycle contract | `ctx.jobs` | -| [`jobs-local/`](jobs-local/README.md) | Implements the process-local job registry | registers on `ctx.jobs` | -| [`tool-jobs/`](tool-jobs/README.md) | Exposes job control and completion notices to the model | registers on `ctx.tools` | +| [`jobs`](jobs/README.md) | Defines the background-job contract: ids, ownership, lifecycle, and completion listeners | `ctx.jobs` | +| [`jobs-local`](jobs-local/README.md) | Runs and stores jobs in this process, fenced per owner | registers on `ctx.jobs` | +| [`tool-jobs`](tool-jobs/README.md) | Lets the model read, list, and kill jobs and delivers completion notices | registers on `ctx.tools` | -See the [background-job runtime](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and [job-registry](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) decisions. +----- -The subsystem reference — the id scheme, the owner-fenced contract, snapshots — is [docs/subsystems/jobs.md](../../docs/subsystems/jobs.md); design in the [background-job runtime](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) and [job-registry contract](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) Agent Notes. + +## Related documentation + +- [Background task runtime subsystem](../../docs/subsystems/jobs.md) — the job types, snapshot fields, and the `ctx.jobs` API. +- [Generic long-running tool runtime Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) — the design behind the background-job runtime. +- [job-registry seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale. + +----- + + +## Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/jobs/README.zh.md b/packages/jobs/README.zh.md index 4e733b1da0..6f75cb3fc4 100644 --- a/packages/jobs/README.zh.md +++ b/packages/jobs/README.zh.md @@ -1,15 +1,50 @@ +--- +description: "jobs 组地图:后台任务控制——注册表约定、进程本地存储与面向模型的任务工具,供浏览本组的用户与维护者阅读。" +kind: "package-group" +--- + # jobs/:后台任务能力家族 [English](README.md) | 中文 -本家族为长时间运行的工具提供一套按所有者隔离的后台任务协议,用于观察、取消、等待和完成通知。 +## 概述 + +jobs 组是后台工作能力家族:运行长时间工作的工具把工作注册为任务,拥有它的 agent 可以在不阻塞自身轮次的情况下读取、等待、列出或取消任务。任务属于启动它的 agent 会话,因此一个 agent 永远不会看到另一个 agent 的工作;任务完成时以会话内通知送达给拥有它的 agent,无需轮询。本组拆分为注册表约定(`jobs`)、其进程本地存储(`jobs-local`)以及带完成通知的模型侧控制工具(`tool-jobs`)。 + +## 目录 + +- [包](#packages) +- [相关文档](#related-documentation) +- [开发备注](#dev-note) + +----- + + +## 包 | 包 | 职责 | ctx 键 | |---|---|---| -| [`jobs/`](jobs/README.md) | 定义任务注册表和生命周期约定 | `ctx.jobs` | -| [`jobs-local/`](jobs-local/README.md) | 实现进程本地任务注册表 | 注册到 `ctx.jobs` | -| [`tool-jobs/`](tool-jobs/README.md) | 向模型公开任务控制和完成通知 | 注册到 `ctx.tools` | +| [`jobs`](jobs/README.zh.md) | 定义后台任务约定:id、归属、生命周期与完成监听器 | `ctx.jobs` | +| [`jobs-local`](jobs-local/README.zh.md) | 在本进程中运行并存储任务,按所有者隔离 | 注册到 `ctx.jobs` | +| [`tool-jobs`](tool-jobs/README.zh.md) | 让模型读取、列出和终止任务,并投递完成通知 | 注册到 `ctx.tools` | -参见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和[任务注册表](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)决策。 +----- -子系统参考文档——id 方案、所有者隔离约定、快照——见 [docs/subsystems/jobs.md](../../docs/subsystems/jobs.md);设计见[后台任务运行时](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)与[任务注册表约定](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)两篇 Agent Note。 + +## 相关文档 + +- [后台任务运行时子系统](../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` API。 +- [通用长时间运行工具运行时 Agent Note](../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)——后台任务运行时背后的设计。 +- [任务注册表 seam Agent Note](../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)——按所有者隔离的注册表约定及其理由。 + +----- + + +## 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/jobs/jobs-local/README.i18n.yaml b/packages/jobs/jobs-local/README.i18n.yaml index 7d7f6d2c9b..6e514ac7e3 100644 --- a/packages/jobs/jobs-local/README.i18n.yaml +++ b/packages/jobs/jobs-local/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/jobs/jobs-local/README.md -README.md: e55d9d1747e0dbb12d22f2528527312334f352e6 -README.zh.md: 78979d9518b1b588c689ce5184b33d79177a1ec0 +README.md: cfbda98ccf59bcd12e931350c708cc15b30ec54c +README.zh.md: 07acfc3c75ef730328792ab1aa45197bfcd5c849 diff --git a/packages/jobs/jobs-local/README.md b/packages/jobs/jobs-local/README.md index e55d9d1747..cfbda98ccf 100644 --- a/packages/jobs/jobs-local/README.md +++ b/packages/jobs/jobs-local/README.md @@ -1,34 +1,142 @@ +--- +description: "The process-local background-job registry for users and maintainers composing, sizing, or debugging in-process jobs: per-owner admission, lifecycle, and teardown." +kind: "package-reference" +--- + # @deepseek-ai/dsh-jobs-local English | [中文](README.zh.md) -Process-local implementation of the [`@deepseek-ai/dsh-jobs`](../jobs/README.md) registry contract: `LocalJobRegistry` keeps every record in memory, issues per-kind `-N` ids, and hands out fresh snapshots, never live state. Load it as a plugin and it registers as `ctx.jobs`. +## Summary -## Admission +`dsh-jobs-local` runs background jobs inside the harness process: work keeps running while the agent moves on, and the owning agent can read, wait on, list, and cancel it, with completion delivered as an in-session notice when `dsh-tool-jobs` is also mounted. It implements the `dsh-jobs` contract with in-memory records handed out as fresh snapshots, never live state. A per-owner concurrency limit (default 10) bounds how many jobs one agent can have running or stopping at once; jobs die with the harness process and are not durable across restarts. -`maxConcurrentJobsPerOwner` is a positive safe integer and defaults to `10`. Before invoking a producer, `start()` counts the exact owner's `running` and `stopping` records; all unowned jobs share one separate service bucket. Terminal history does not occupy capacity, and only producer `done` settlement releases a stopping job's place. +## Table of Contents -At capacity, `start()` fails before producer execution and id allocation with an error that names the limit and tells the model to use `job_kill`, wait for the job to finish stopping, and retry. The registry does not queue, preempt, or maintain a second mutable counter. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -## Lifecycle +----- -Jobs belong to their owner and backend, not the producer tool fiber, so producer and controller reloads do not stop them. The first job for an owner attaches one awaited effect to the exact `Agent` scope. Owner disposal cancels that object's jobs, awaits producer quiescence, and removes their snapshots; reused agent or session ids cannot redirect an old cleanup. + +## Use this package -Service disposal closes listeners, cancels all live jobs, awaits their records, and detaches effects from surviving owner scopes. If teardown cancellation throws, the service force-fails the record and warns that work may be orphaned instead of deadlocking. A cancellation that returns but never settles `done` remains indistinguishable from a slow stop and can stall teardown. +Load this plugin when a composition needs in-process background jobs: long-running tools register their work, and the owning agent reads, waits on, lists, and cancels it without blocking its own turn. It implements the [`dsh-jobs`](../jobs/README.md) contract; the model-facing `job_output`, `job_list`, and `job_kill` tools come from [`dsh-tool-jobs`](../tool-jobs/README.md). -Settlement is first-wins: the earliest terminal outcome — producer settlement, a rejected `done` contained as `failed`, or a teardown force-failure — records once, releases waiters, and notifies listeners once with per-listener containment. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices, and a teardown cancel marks it for the same reason: nothing will read a notice addressed to an owner being destroyed. Completion is the last thing a settlement announces, after the record is committed and the visible-set change is published, because a reporter may open a model turn synchronously and every other observer must already have seen the settled record. +### When to choose it -Controllers and listeners are layered by the scope that registered them, in the tools-registry shape: a registration files into its registering context's scope, and a read unions the global layer with the owner's scope chain. One process-wide registry therefore answers per-owner questions per owner — `start()` refuses `background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)` for an owner whose own composition attaches none, however many other compositions attach theirs, and a settlement reaches only the listeners its owner's composition registered. +Choose it when jobs should live in the harness process and die with it. Avoid it when work must survive a restart or span processes: records are in-memory, so a durable or cross-process backend must implement the same contract differently. +### Minimal configuration + +Loading the plugin registers `ctx.jobs`; `maxConcurrentJobsPerOwner` is optional and defaults to `10`. + +```yaml +- name: '@deepseek-ai/dsh-jobs-local' +``` + +| Field | Default | Meaning | +|---|---|---| +| `maxConcurrentJobsPerOwner` | `10` | Maximum `running` plus `stopping` jobs per exact owner, or in the shared unowned bucket | + +The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-jobs-local) is the exhaustive source for the accepted field. + +### What each owner gets + +The limit counts the exact owner's `running` and `stopping` records; all unowned jobs share one separate service-level bucket. Terminal history does not occupy capacity, and only a producer's `done` settlement releases a stopping job's place. At capacity, `start()` fails before the producer runs, with an error that names the limit and tells the agent to kill an unneeded job, wait for it to finish, and retry — the registry neither queues nor preempts. + +### Lifecycle + +Jobs belong to their owner and backend, not to the producer tool, so producer or controller reloads do not stop them. When an agent that owns jobs is disposed, its jobs are cancelled, their producers awaited, and their snapshots removed; service disposal does the same for every remaining job. A cancellation that throws during teardown force-fails the record and warns that the work may be orphaned, so teardown never deadlocks. + +### What can go wrong + +Starting work fails without a controller that serves the owner — loading `dsh-tool-jobs` attaches one, and `start()` otherwise refuses with a message naming it. A producer cancel that returns without settling `done` stays indistinguishable from a slow stop and can stall teardown while holding one capacity slot. Every record disappears when the harness process exits. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains the design decisions behind the registry and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package). + +### Design philosophy + +- **In-memory records, fresh snapshots.** `LocalJobRegistry` keeps one `TrackedTask` per job and projects a new read-only snapshot per call; callers never receive live state. +- **Owner-relative layers, one process-wide registry.** Controllers, completion listeners, and change observers are filed into the scope that registered them (`ScopedLayers`), and reads union the global layer with the owner's scope chain — so one preset's job controls never hold `start()` open for an agent whose own composition loads none, and a settlement reaches only the listeners its owner's composition registered. +- **Preflight before start.** `start()` checks controller service, spec validity, live ownership, and capacity before invoking the producer, so a rejection leaves no job id or execution resource; registration commits without a later failable step. +- **First-wins settlement, completion last.** The earliest terminal outcome records once, releases waiters, and notifies listeners once with per-listener containment; completion is announced after the record is committed and the visible-set change published, because a reporter may open a model turn synchronously. +- **Teardown never deadlocks.** A throwing cancel force-fails the record and reports a possible orphan instead of stalling disposal. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Plugin entry: `Config` schema, `LocalJobRegistry`, admission, lifecycle, teardown | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion (no runtime invariant; snapshot checks live in `dsh-jobs/invariant`) | + +### Scope layers + +`attachController`, `onJobDone`, and `onJobsChanged` register into the calling context's scope layer. The controller question (`servesOwner`) and listener delivery (`listenersFor`, `changedFor`) walk the same chain: global layer first, then each scoped layer along the owner's chain. Registrations are anonymous tokens so duplicate labels stay independently disposable. + +### Admission and settlement + +`activeTaskCount` counts authoritative records per exact owner or in the shared unowned bucket. `settle` marks a job reported when waiters are pending, resolves every waiter, records the terminal snapshot, announces the visible-set change, then notifies completion listeners. Pending waits mark the job reported before listeners run so completion reporters do not duplicate notices; a teardown cancel marks it for the same reason — nothing will read a notice addressed to an owner being destroyed. + +### Teardown + +Owner disposal (`disposeOwned`) cancels the owner's jobs, awaits their settlement, removes their records, and announces the removal — the one visible-set change no per-job record carries. Service disposal (`disposeAll`) closes listeners, cancels all live jobs, awaits settlement, clears the store, announces the emptying to the distinct owners, then detaches the cross-fiber owner-cleanup effects. + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the registry contract to the model-facing controls and the design records. + +- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface. +- [jobs group map](../README.md) — the sibling group page and its package table. +- [Registry contract](../jobs/README.md) — the abstract `ctx.jobs` service this package implements. +- [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices. +- [Generic long-running tool runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) — the design behind the background-job runtime. +- [job-registry seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale. + +----- + + ## Model Experience -Indirectly, through producer plugins and [`dsh-tool-jobs`](../tool-jobs/README.md), which render job ids, output, status, cancellation, and completion notices. +Indirectly, through producer plugins and `dsh-tool-jobs`, to which the registry backend delegates all model rendering. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work + + + +These limits define when the registry is a poor fit. They are current package constraints, not a task backlog. + - **Jobs are process-local** — records die with the harness process; durable or cross-restart execution needs a separate backend implementing the seam. - **A silently ineffective cancel can stall teardown and hold capacity** — if `cancel` returns without settling `done`, the registry cannot distinguish it from a slow stop; the job keeps one bucket slot for the rest of the service lifetime, and only an explicit throw can be force-failed safely. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/jobs/jobs-local/README.zh.md b/packages/jobs/jobs-local/README.zh.md index 78979d9518..07acfc3c75 100644 --- a/packages/jobs/jobs-local/README.zh.md +++ b/packages/jobs/jobs-local/README.zh.md @@ -1,34 +1,142 @@ +--- +description: "进程本地后台任务注册表,供组合、容量评估或排查进程内任务的用户与维护者阅读:按所有者的准入、生命周期与销毁。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-jobs-local [English](README.md) | 中文 -[`@deepseek-ai/dsh-jobs`](../jobs/README.md) 注册表约定的进程本地实现:`LocalJobRegistry` 把每条记录保存在内存中,按 kind 签发 `-N` id,并且只交出全新快照,从不交出实时状态。作为插件加载后即注册为 `ctx.jobs`。 +## 概述 -## 准入 +`dsh-jobs-local` 在 harness 进程内运行后台任务:工作会在 agent 继续推进的同时保持运行,拥有它的 agent 可以读取、等待、列出和取消它;同时挂载 `dsh-tool-jobs` 时,完成以会话内通知送达。它用内存记录实现 `dsh-jobs` 约定,并且只交出全新快照,从不交出实时状态。按所有者的并发上限(默认 10)约束一个 agent 同时处于运行或停止中的任务数量;任务会随 harness 进程终止而消失,无法跨重启持久。 -`maxConcurrentJobsPerOwner` 必须是正的安全整数,默认值为 `10`。调用生产方之前,`start()` 会统计确切 owner 的 `running` 与 `stopping` 记录;所有无 owner 任务共享另一个独立的服务级桶。终止历史不占用容量,处于 `stopping` 的任务只有在生产方 `done` 结算后才释放名额。 +## 目录 -达到容量时,`start()` 会在生产方执行和 id 分配前失败;错误会给出上限,并告诉模型使用 `job_kill`、等待任务完全停稳后再重试。注册表不会排队或抢占任务,也不会维护第二份可变计数。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -## 生命周期 +----- -任务属于其所有者和后端,而不是生产方工具 fiber,因此重载生产方或控制器不会停止任务。某个所有者的第一个任务会把一个会被等待的 effect 附加到对应 `Agent` 对象的 scope 上。所有者的 dispose(资源释放)会取消该对象的任务,等待生产方完全停稳,并移除其快照;复用的 agent(智能体)id 或会话 id 无法重定向旧的清理操作。 + +## 使用本包 -服务 dispose 会关闭监听器、取消所有存活任务、等待其记录完成,并从仍存活的所有者 scope 中分离 effect。如果销毁期间的取消操作抛出异常,服务会强制将记录标为失败,并警告工作可能成为孤立工作,而不会死锁。取消操作已返回但 `done` 始终未结算时,系统无法将其与缓慢停止区分开,销毁过程可能因此停滞。 +当组合需要进程内后台任务时加载本插件:长时间运行的工具注册其工作,拥有它的 agent 在不阻塞自身轮次的情况下读取、等待、列出和取消。它实现 [`dsh-jobs`](../jobs/README.zh.md) 约定;模型侧的 `job_output`、`job_list` 与 `job_kill` 工具来自 [`dsh-tool-jobs`](../tool-jobs/README.zh.md)。 -结算遵循首次结算优先原则:最早出现的终止结果(生产方结算、作为 `failed` 隔离处理的 `done` 拒绝,或销毁时的强制失败)只记录一次,随后释放等待方,再只通知监听器一次;各监听器的故障会单独隔离。挂起的等待会在监听器运行前把任务标记为已报告,因此完成报告方不会重复发出通知;销毁时的取消出于同样的理由也会标记:面向正在被销毁的所有者的通知不会有人读到。完成是一次结算最后才宣布的事情,排在记录提交与可见集变更发布之后,因为报告方可能同步开启一个模型轮次,而该结算的其他所有观察者都必须已经看到已结算的记录。 +### 何时选择 -控制器与监听器按注册方所在的 scope 分层,形状与 tools 注册表一致:一次注册归档到其注册上下文的 scope,一次读取则把全局层与所有者的 scope 链求并集。因此一个进程级注册表能逐所有者地回答逐所有者的问题——对自身组合未附加任何控制器的所有者,无论其他组合附加了多少,`start()` 都会拒绝并抛出 `background jobs unavailable: no job controller serves this agent (load @deepseek-ai/dsh-tool-jobs in its composition)`;一次结算也只会抵达其所有者所属组合注册的监听器。 +当任务应存活于 harness 进程内、并随进程终止时选择它。当工作必须跨重启存活或跨进程存在时避免它:记录保存在内存中,持久或跨进程后端必须以不同方式实现同一约定。 +### 最小配置 + +加载插件即注册 `ctx.jobs`;`maxConcurrentJobsPerOwner` 可选,默认为 `10`。 + +```yaml +- name: '@deepseek-ai/dsh-jobs-local' +``` + +| 字段 | 默认值 | 含义 | +|---|---|---| +| `maxConcurrentJobsPerOwner` | `10` | 每个精确所有者,或共享的无主桶中,`running` 加 `stopping` 任务的最大数量 | + +生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-jobs-local)是每个受支持字段的穷尽式真源。 + +### 每个所有者得到什么 + +上限统计精确所有者的 `running` 与 `stopping` 记录;所有无主任务共享另一个独立的服务级桶。终止历史不占用容量,只有生产方的 `done` 结算才释放一个停止中任务的名额。达到上限时,`start()` 会在生产方运行前失败,错误会指出上限并告诉 agent 终止一个不需要的任务、等它结束后再重试——注册表既不排队也不抢占。 + +### 生命周期 + +任务属于其所有者和后端,而非生产方工具,因此重载生产方或控制器不会停止任务。拥有任务的 agent 被释放时,其任务会被取消、生产方会被等待、快照会被移除;服务释放对每个剩余任务执行同样的操作。销毁期间抛出的取消会强制失败记录并警告工作可能成为孤立工作,因此销毁永远不会死锁。 + +### 可能出什么问题 + +没有服务于所有者的控制器时无法启动工作——加载 `dsh-tool-jobs` 即附加一个,否则 `start()` 会以指出它的消息拒绝。返回但始终未结算 `done` 的生产方取消与缓慢停止无法区分,可能使销毁停滞并持续占用一个容量名额。每条记录都会在 harness 进程退出时消失。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释注册表背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 + +### 设计理念 + +- **内存记录,全新快照。** `LocalJobRegistry` 为每个任务保存一条 `TrackedTask`,每次调用都投影出新的只读快照;调用方永远不会拿到实时状态。 +- **按所有者分层,一个进程级注册表。** 控制器、完成监听器与变更观察者归档到注册方所在的 scope(`ScopedLayers`),读取把全局层与所有者的 scope 链求并集——因此某个 preset 的任务控制绝不会为自身组合未加载任何控制器的 agent 保持 `start()` 可用,一次结算也只会抵达其所有者所属组合注册的监听器。 +- **启动前先预检。** `start()` 在调用生产方之前检查控制器服务、spec 有效性、仍存活的所有权与容量,因此拒绝不会留下 job id 或执行资源;注册一旦提交,后续不再有可失败步骤。 +- **结算首次优先,完成最后。** 最早的终止结果只记录一次,释放等待方,并只通知监听器一次,各监听器故障单独隔离;完成在记录提交且可见集变更发布之后才宣布,因为报告方可能同步开启一个模型轮次。 +- **销毁永不死锁。** 抛出的取消会强制失败记录并报告可能的孤立工作,而不是让释放停滞。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 插件入口:`Config` schema、`LocalJobRegistry`、准入、生命周期、销毁 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件(无运行时不变式;快照检查位于 `dsh-jobs/invariant`) | + +### scope 分层 + +`attachController`、`onJobDone` 与 `onJobsChanged` 注册到调用上下文所在的 scope 层。控制器问题(`servesOwner`)与监听器投递(`listenersFor`、`changedFor`)走同一条链:先是全局层,再沿所有者的链逐层。注册是无名 token,因此重复标签仍可独立释放。 + +### 准入与结算 + +`activeTaskCount` 按精确所有者或共享无主桶统计权威记录。`settle` 在存在挂起等待方时把任务标为已报告,解析每个等待方,记录终止快照,宣布可见集变更,然后通知完成监听器。挂起的等待会在监听器运行前把任务标为已报告,因此完成报告方不会重复通知;销毁时的取消出于同样理由标记——面向正在被销毁的所有者的通知不会有人读到。 + +### 销毁 + +所有者释放(`disposeOwned`)会取消该所有者的任务、等待其结算、移除其记录,并宣布移除——这是任何逐任务记录都无法表达的可见集变更。服务释放(`disposeAll`)会关闭监听器、取消所有存活任务、等待结算、清空存储、向不同的所有者宣布清空,然后分离跨 fiber 的所有者清理 effect。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从注册表约定逐步进入模型侧控制与设计记录。 + +- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。 +- [jobs 组映射](../README.zh.md)——同级组页面及其包表格。 +- [注册表约定](../jobs/README.zh.md)——本包实现的抽象 `ctx.jobs` 服务。 +- [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。 +- [通用长时间运行工具运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)——后台任务运行时背后的设计。 +- [任务注册表 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)——按所有者隔离的注册表约定及其理由。 + +----- + + ## 模型体验 -通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会呈现 job id、输出、状态、取消和完成通知。 +通过生产方插件与 `dsh-tool-jobs` 间接影响模型,注册表后端把全部模型渲染委托给它们。 #### KV Cache 影响 不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **任务只存在于进程本地**:记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。 -- **静默无效的取消可能使销毁过程停滞并持续占用容量**:如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。 + + + +这些限制说明注册表何时不合适。它们是当前包约束,不是任务积压。 + +- **任务只存在于进程本地**——记录会随 harness 进程终止而消失;持久或跨重启执行需要一个单独实现该 seam 的后端。 +- **静默无效的取消可能使销毁停滞并持续占用容量**——如果 `cancel` 返回后始终未结算 `done`,注册表就无法将其与缓慢停止区分开;该任务会在服务剩余生命周期内持续占用一个桶名额,只有显式抛出异常才能安全地强制标为失败。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/jobs/jobs-local/package.json b/packages/jobs/jobs-local/package.json index a615cded8b..117f9d9e99 100644 --- a/packages/jobs/jobs-local/package.json +++ b/packages/jobs/jobs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs-local", "description": "Process-local implementation of the DeepSeek Harness background job registry seam", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/jobs/README.i18n.yaml b/packages/jobs/jobs/README.i18n.yaml index 0473d974f8..5badbfe671 100644 --- a/packages/jobs/jobs/README.i18n.yaml +++ b/packages/jobs/jobs/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/jobs/jobs/README.md -README.md: f4469286603f687be5789bd016ab462dba26e664 -README.zh.md: 30d8e4cb1e298bb4a3a0a3e65bb0858b2458a277 +README.md: 81786dfca1095c311e2ded3cd4140fa7997e2158 +README.zh.md: 5e27e7dad5286fc9ef758ca8633488495409ce37 diff --git a/packages/jobs/jobs/README.md b/packages/jobs/jobs/README.md index f446928660..81786dfca1 100644 --- a/packages/jobs/jobs/README.md +++ b/packages/jobs/jobs/README.md @@ -1,40 +1,132 @@ +--- +description: "The background-job registry contract for users and maintainers composing, implementing, or debugging background work: ids, ownership, lifecycle, and completion listeners." +kind: "package-reference" +--- + # @deepseek-ai/dsh-jobs English | [中文](README.zh.md) -The background job registry contract (`ctx.jobs`). The abstract `JobRegistry` and its vocabulary types give long-running producers shared ids, owner isolation, reads, cancellation, waiting, notices, and cleanup under one contract; the process-local registry lives in [`dsh-jobs-local`](../jobs-local/README.md). Producer plugins extend `JobKindMap` with their opaque id namespace. +## Summary -## Service contract +`dsh-jobs` lets tools run long work as background jobs: the work gets a stable `-N` id, keeps running while the agent moves on, and the owning agent can read its output, wait for it with a timeout, or request cancellation at any time. Jobs belong to the agent session that started them, so one agent's work is never visible to another, and completion reaches the owner as an in-session notice rather than by polling. This package ships the contract only: the process-local registry lives in `dsh-jobs-local`, and the model-facing controls and completion notices live in `dsh-tool-jobs`. Load an implementation to get background jobs; without one, `ctx.jobs` does not exist and `start()` cannot run. -- `start(spec): JobId` validates the attached controller, spec, exact live owner, optional positive `outputLimitBytes`, and any provider-owned admission policy before calling the producer's `run()` once. A preflight rejection or starter throw leaves no job id or registered work; successful return commits without another failable step. -- `get(id, caller?)` and `list(caller?)` return non-consuming snapshots. Listing includes only caller-owned and unowned jobs. -- `read(id, caller?)` consumes the single cursor for stream jobs and reads terminal output idempotently for final-output jobs. -- `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the job running; success changes it to `stopping` and marks terminal delivery reported. -- `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. -- `onJobDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. -- `onJobsChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned job changed and every caller's set moved with it. It is owner-granular because removal is a change no per-job record can express, and it is not a superset of `onJobDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying. -- `attachController(name)` declares a job controller for its effect lifetime. `start()` fails before producer execution when no attached controller serves the spec's owner. +## Table of Contents -All three registrations are owner-relative, because one registry serves every composition in the process. A controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no controller cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. +- [Use this package](#use-this-package) +- [Understand the implementation](#understand-the-implementation) +- [Further Exploration](#further-exploration) +- [Model Experience](#model-experience) +- [Known Limitations and Deferred Work](#known-limitations-and-deferred-work) +- [Dev Note](#dev-note) -Owned access compares the job's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned jobs are open to callers and last until service disposal. +----- -`outputLimitBytes` is producer-owned model-presentation policy carried unchanged into snapshots. A controller applies it after adding status or notice metadata; the registry does not rewrite producer output or invent a default for producers that omit it. + +## Use this package -Implementations also owe the lifecycle semantics of the contract: registrations outlive producer and controller fibers, owner and service disposal cancel live work and await compliant producers, and settlement is first-wins — one terminal record, one round of contained listener notification, released waiters. +Use this package when you are composing a background-job capability or writing a producer that registers long work. The package itself defines the contract; a composition gets the feature by loading an implementation such as `dsh-jobs-local` and, for the model side, `dsh-tool-jobs`. -See the [job type catalog](../../../docs/subsystems/jobs.md), the [runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md), and the [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md). +### What a background job gives you +A producer registers work with a kind and a one-line label; the registry returns a `-N` id such as `bash-1`. Anyone who owns the job can read output, list jobs, wait up to a timeout for settlement, and request cancellation — each call returns a fresh snapshot of the job's status, from `running` and `stopping` to the terminal `completed`, `killed`, or `failed`. When a job settles, the owning agent is notified through the completion listener that `dsh-tool-jobs` turns into an in-session notice, so no polling is needed. A producer may attach an optional byte cap so each complete model-facing output read or completion notice stays bounded. + +### The ownership boundary + +A job belongs to the agent session that started it: another agent cannot read or stop it. Ids such as `bash-1` are predictable, so this fence is authorization, not secrecy. A job started without an owner is open to any caller and lasts until the service is disposed. + +### Starting background work needs a controller + +A producer can start work only while a controller that serves the owner is attached — loading `dsh-tool-jobs` attaches one. An agent whose composition loads no controller cannot start background work; `start()` fails with a message that names the missing controller rather than starting work the agent could never collect or stop. + +### Smallest working composition + +```yaml +- name: '@deepseek-ai/dsh-jobs-local' +- name: '@deepseek-ai/dsh-tool-jobs' +``` + +Loading these two plugins on a harness base that already provides the agent, tools, and system-prompt services gives the full feature: `dsh-jobs-local` provides the in-process background-job registry, and `dsh-tool-jobs` provides the `job_output`, `job_list`, and `job_kill` tools plus completion-notice delivery. + +### What can go wrong + +Any preflight rejection leaves no job id or registered work. Jobs managed by the shipped in-process registry die with the harness process; durable execution across restarts needs a different backend implementing this contract. + +----- + + +## Understand the implementation + +
+Implementation internals — click to expand + +This section explains the design decisions behind the contract and points at the code that realizes them; the observable behavior is fully covered in [Use this package](#use-this-package). + +### Design philosophy + +- **Contract and implementation are separate packages.** `JobRegistry` is an abstract Cordis service; loading the class directly throws, so a misconfigured composition fails at load instead of registering an empty `ctx.jobs`. +- **One registry per process, owner-relative answers.** One instance serves every composition in the process, so registrations and deliveries are relative to the registering scope: a controller or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. +- **Access is fenced by the owner's session id.** Ids are predictable, so authorization — not secrecy — is the boundary. +- **Settlement is first-wins, and completion is announced last.** One terminal record, released waiters, and one round of contained listener notification; completion is announced after the record is committed and every other observer has seen it, because a reporter may open a model turn synchronously. +- **Registrations outlive producer and controller fibers.** Owner and service disposal cancel live work and await compliant producers; a throwing teardown cancel force-fails only the record. + +### Source map + +| File | Role | +|---|---| +| [`src/index.ts`](src/index.ts) | Plugin entry: the abstract `JobRegistry` service and its contract | +| [`src/types.ts`](src/types.ts) | Shared vocabulary: `JobKindMap`, `JobStart`, `JobHooks`, `JobSnapshot`, listener types | +| [`src/brand.ts`](src/brand.ts) | `JobId` branded identifier, importable without the agent dependency | +| [`src/invariant.ts`](src/invariant.ts) | Invariant companion: validates snapshot identity, status, timestamps, and owner fields | + +### Service operations + +Every operation is a thin projection over the registered jobs: `get` and `list` return non-consuming snapshots, `read` advances the single stream cursor, `kill` invokes producer cancellation before changing status, `wait` blocks up to a timeout, and `start()` preflights access, validation, and admission before invoking the producer's `run()` once while refusing any owner no attached controller serves; listeners observe terminal records and visible-set changes at owner granularity, and `attachController` scopes controller availability to its effect lifetime. Exact signatures and behavior live in the JSDoc on [`src/index.ts`](src/index.ts) and the generated [`ctx.jobs` cordis surface](../../../docs/subsystems/jobs.md). + +
+ +----- + + +## Further Exploration + +Read these pages when the package-level contract is not enough. They move from the job types to the shipped implementation, the model-facing controls, and the design records. + +- [Background task runtime subsystem](../../../docs/subsystems/jobs.md) — the job types, snapshot fields, and `ctx.jobs` cordis surface. +- [jobs group map](../README.md) — the sibling group page and its package table. +- [Process-local registry](../jobs-local/README.md) — the shipped implementation that runs jobs in this process. +- [Model-facing job controls](../tool-jobs/README.md) — the `job_output`, `job_list`, and `job_kill` tools and completion notices. +- [Generic long-running tool runtime Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md) — the design behind the background-job runtime. +- [job-registry seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md) — the owner-fenced registry contract and its rationale. + +----- + + ## Model Experience -Indirectly, through producer plugins and [`dsh-tool-jobs`](../tool-jobs/README.md), which render job ids, output, status, cancellation, and completion notices. +Indirectly, through producer and controller plugins, which own all model rendering over the job registry. #### KV Cache effect -No direct invalidation; the named consumer owns any request-prefix changes. +No direct invalidation; the named consumers own any request-prefix changes. ## Known Limitations and Deferred Work + + + +These limits define when the contract is a poor fit. They are current package constraints, not a task backlog. + +- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. - **Stream output has one consuming cursor** — independent observers need a cursor or snapshot API. - **Foreground work cannot be promoted** — producers choose foreground or background before starting. -- **The contract is in-process** — `JobStart.run()` passes callbacks and exact `Agent` objects; a durable or cross-process backend must reshape identity, restart, ownership, and observation semantics before it can implement this seam. + + +### Dev Note + +
+Working context for maintainers — click to expand + +None. + +
diff --git a/packages/jobs/jobs/README.zh.md b/packages/jobs/jobs/README.zh.md index 30d8e4cb1e..5e27e7dad5 100644 --- a/packages/jobs/jobs/README.zh.md +++ b/packages/jobs/jobs/README.zh.md @@ -1,40 +1,132 @@ +--- +description: "后台任务注册表约定,供组合、实现或排查后台工作的用户与维护者阅读:id、归属、生命周期与完成监听器。" +kind: "package-reference" +--- + # @deepseek-ai/dsh-jobs [English](README.md) | 中文 -后台任务注册表约定(`ctx.jobs`)。抽象的 `JobRegistry` 及其词汇类型在同一份约定下为长时间运行的生产方提供共享 id、owner 隔离、读取、取消、等待、通知和清理;进程局部注册表位于 [`dsh-jobs-local`](../jobs-local/README.md)。生产方插件使用其不透明 id namespace 扩展 `JobKindMap`。 +## 概述 -## 服务约定 +`dsh-jobs` 让工具可以把长时间工作注册为后台任务:工作获得稳定的 `-N` id,在 agent 继续推进的同时保持运行,拥有它的 agent 可以随时读取输出、带超时等待或请求取消。任务属于启动它的 agent 会话,因此一个 agent 的工作永远不会被另一个 agent 看到;完成以会话内通知而非轮询的方式送达给拥有者。本包只提供约定:进程本地注册表位于 `dsh-jobs-local`,模型侧控制与完成通知位于 `dsh-tool-jobs`。加载一个实现才能获得后台任务;没有实现时 `ctx.jobs` 不存在,`start()` 无法运行。 -- `start(spec): JobId` 验证已附加的任务控制器、spec、确切且仍存活的 owner、可选的正数 `outputLimitBytes`,以及 Service Provider 所拥有的准入策略,然后只调用生产方的 `run()` 一次。预检拒绝或启动方抛出异常时都不会生成 job id 或注册工作;成功返回会直接提交,不再执行其他可能失败的步骤。 -- `get(id, caller?)` 和 `list(caller?)` 返回非消费式快照。列表只包含调用方拥有及无 owner 的任务。 -- `read(id, caller?)` 消费流任务的唯一游标;对于最终输出任务,则以幂等方式读取终止输出。 -- `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 -- `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 -- `onJobDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 -- `onJobsChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onJobDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。 -- `attachController(name)` 在其 effect 生命周期内声明任务控制器。当没有任何已附加的控制器服务于 spec 的所有者时,`start()` 会在生产方执行前失败。 +## 目录 -这三类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制器的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 +- [使用本包](#use-this-package) +- [理解实现](#understand-the-implementation) +- [进一步探索](#further-exploration) +- [模型体验](#model-experience) +- [已知限制与延期工作](#known-limitations-and-deferred-work) +- [开发备注](#dev-note) -有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务 dispose(资源释放)为止。 +----- -`outputLimitBytes` 是生产方拥有的模型呈现策略,会原样携带到快照中。控制器在添加状态或通知元数据后应用它;注册表不会重写生产方输出,也不会为省略此字段的生产方虚构默认值。 + +## 使用本包 -实现还必须兑现约定的生命周期语义:注册的存续期长于生产方 fiber 与控制器 fiber,owner 释放和服务释放会取消仍在运行的工作并等待守约的生产方,结算遵循首次结果优先(一条终止记录、一轮异常受到隔离的监听器通知,然后释放等待方)。 +在组合后台任务能力或编写注册长时间工作的生产方时使用本包。本包本身定义约定;组合通过加载 `dsh-jobs-local` 这样的实现,以及模型侧的 `dsh-tool-jobs`,获得该功能。 -参见[任务类型目录](../../../docs/subsystems/jobs.md)、[运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.md)和 [seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.md)。 +### 后台任务提供什么 +生产方以 kind 和一行标签注册工作;注册表返回 `-N` id,例如 `bash-1`。拥有任务的任何一方都可以读取输出、列出任务、带超时等待结算或请求取消——每次调用都返回任务状态的全新快照,从 `running`、`stopping` 到终止态的 `completed`、`killed` 或 `failed`。任务结算时,拥有它的 agent 会通过 `dsh-tool-jobs` 转成会话内通知的完成监听器得到通知,因此无需轮询。生产方还可以附加可选的字节上限,让每次完整的模型侧输出读取或完成通知保持有界。 + +### 归属边界 + +任务属于启动它的 agent 会话:其他 agent 无法读取或停止它。`bash-1` 这样的 id 可预测,因此这道隔离是授权,而非保密。没有所有者启动的任务对任何调用方开放,并持续到服务被释放为止。 + +### 启动后台工作需要一个控制器 + +只有附加了服务于所有者的控制器时,生产方才能启动工作——加载 `dsh-tool-jobs` 即附加一个。组合中未加载任何控制器的 agent 无法启动后台工作;`start()` 会以指出缺失控制器的消息失败,而不会启动 agent 永远无法收集或停止的工作。 + +### 最小可用组合 + +```yaml +- name: '@deepseek-ai/dsh-jobs-local' +- name: '@deepseek-ai/dsh-tool-jobs' +``` + +在已提供 agent、tools 与 system-prompt 服务的 harness 基础上加载这两个插件,即可获得完整功能:`dsh-jobs-local` 提供进程内后台任务注册表,`dsh-tool-jobs` 提供 `job_output`、`job_list`、`job_kill` 工具以及完成通知投递。 + +### 可能出什么问题 + +任何预检拒绝都不会留下 job id 或已注册的工作。由随附的进程内注册表管理的任务会随 harness 进程终止而消失;跨重启的持久执行需要一个实现本约定的不同后端。 + +----- + + +## 理解实现 + +
+实现细节——点击展开 + +本节解释约定背后的设计决策,并指出实现它们的代码位置;可观察行为已在[使用本包](#use-this-package)中完整说明。 + +### 设计理念 + +- **约定与实现分属不同包。** `JobRegistry` 是抽象 Cordis 服务;直接加载该类会抛出异常,因此错误配置的组合会在加载时失败,而不是注册一个空的 `ctx.jobs`。 +- **每进程一个注册表,按所有者给出答案。** 一个实例服务进程内的每套组合,因此注册与投递都相对注册方所在 scope:从不带 scope 的上下文注册的控制器或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,恰好服务于该组合下组合出的 agent。 +- **访问以所有者的会话 id 为界。** id 可预测,因此是授权——而非保密——构成边界。 +- **结算首次优先,完成最后宣布。** 一条终止记录、释放的等待方,以及一轮受到隔离的监听器通知;完成在记录提交且该结算的所有其他观察者都已看到之后才宣布,因为报告方可能同步开启一个模型轮次。 +- **注册的存续期长于生产方与控制器 fiber。** 所有者与服务释放会取消正在运行的工作并等待守约的生产方;抛出异常的销毁取消只强制失败记录。 + +### 源码地图 + +| 文件 | 职责 | +|---|---| +| [`src/index.ts`](src/index.ts) | 插件入口:抽象 `JobRegistry` 服务及其约定 | +| [`src/types.ts`](src/types.ts) | 共享词汇:`JobKindMap`、`JobStart`、`JobHooks`、`JobSnapshot`、监听器类型 | +| [`src/brand.ts`](src/brand.ts) | `JobId` 带类型标记的标识符,无需 agent 依赖即可导入 | +| [`src/invariant.ts`](src/invariant.ts) | 不变式伴生插件:校验快照标识、状态、时间戳与所有者字段 | + +### 服务操作 + +每个操作都是已注册任务之上的薄投影:`get` 与 `list` 返回非消费式快照,`read` 推进唯一的流游标,`kill` 在改变状态前调用生产方取消,`wait` 阻塞至超时,`start()` 在调用生产方 `run()` 一次之前预检访问、校验与准入,同时拒绝任何没有已附加控制器服务的所有者;监听器按所有者粒度观察终止记录与可见集变化,`attachController` 把控制器可用性限定在其 effect 生命周期内。确切签名与行为见 [`src/index.ts`](src/index.ts) 的 JSDoc 与生成的 [`ctx.jobs` cordis 接口面](../../../docs/subsystems/jobs.zh.md)。 + +
+ +----- + + +## 进一步探索 + +当包级约定不够用时阅读以下页面。它们从任务类型逐步进入随附实现、模型侧控制与设计记录。 + +- [后台任务运行时子系统](../../../docs/subsystems/jobs.zh.md)——任务类型、快照字段与 `ctx.jobs` 的 cordis 接口面。 +- [jobs 组映射](../README.zh.md)——同级组页面及其包表格。 +- [进程本地注册表](../jobs-local/README.zh.md)——在本进程中运行任务的随附实现。 +- [模型侧任务控制](../tool-jobs/README.zh.md)——`job_output`、`job_list` 与 `job_kill` 工具及完成通知。 +- [通用长时间运行工具运行时 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-generic-long-running-tool-runtime.zh.md)——后台任务运行时背后的设计。 +- [任务注册表 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-07-26-job-registry-seam.zh.md)——按所有者隔离的注册表约定及其理由。 + +----- + + ## 模型体验 -通过生产方插件和 [`dsh-tool-jobs`](../tool-jobs/README.md) 间接影响;它们会渲染 job id、输出、状态、取消和完成通知。 +通过生产方插件与控制器插件间接影响模型,它们拥有任务注册表上的全部模型渲染。 #### KV Cache 影响 不会直接导致 KV Cache 失效;请求前缀变更由上述消费方负责。 -## 已知限制与暂缓事项 +## 已知限制与延期工作 -- **流输出只有一个消费游标**:独立观察者需要游标或快照 API。 -- **前台工作无法转为后台**:生产方在启动前选择前台或后台。 -- **约定是进程内的**:`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 + + + +这些限制说明约定何时不合适。它们是当前包约束,不是任务积压。 + +- **约定是进程内的**——`JobStart.run()` 传入回调和确切的 `Agent` 对象;持久化或跨进程后端必须先重塑身份、重启、所有权与观察语义,才能实现此 seam。 +- **流输出只有一个消费游标**——独立观察者需要游标或快照 API。 +- **前台工作无法转为后台**——生产方在启动前选择前台或后台。 + + +### 开发备注 + +
+维护者的工作上下文——点击展开 + +无。 + +
diff --git a/packages/jobs/jobs/package.json b/packages/jobs/jobs/package.json index 6734d1ea37..c658d2d49f 100644 --- a/packages/jobs/jobs/package.json +++ b/packages/jobs/jobs/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jobs", "description": "Background job registry (ctx.jobs) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.1.0-rc.7", + "version": "0.1.1-rc.2", "publishConfig": { "access": "public" }, diff --git a/packages/jobs/tool-jobs/README.i18n.yaml b/packages/jobs/tool-jobs/README.i18n.yaml index e1a51a2cbe..c17b3e0f51 100644 --- a/packages/jobs/tool-jobs/README.i18n.yaml +++ b/packages/jobs/tool-jobs/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/jobs/tool-jobs/README.md -README.md: 8d6a00651f69258d764904ac99d000c2055982dc -README.zh.md: 15903b6f263fe9d225c48bf567c46f587d23cb00 +README.md: f88b7a6643d0fb7c4f3a5196fa286bf205a29255 +README.zh.md: 0140e5d2cdda21d8c18ca516c159c36036a53281 diff --git a/packages/jobs/tool-jobs/README.md b/packages/jobs/tool-jobs/README.md index 8d6a00651f..f88b7a6643 100644 --- a/packages/jobs/tool-jobs/README.md +++ b/packages/jobs/tool-jobs/README.md @@ -1,42 +1,118 @@ +--- +description: "The model-facing background-job controls for users and maintainers choosing, configuring, or debugging job_output, job_list, job_kill, and completion notices." +kind: "package-reference" +--- + # @deepseek-ai/dsh-tool-jobs English | [中文](README.zh.md) -The model-facing controller for `ctx.jobs`: three kind-independent tools, completion notices, and one background-work prompt section. Loading the plugin attaches the controller required by `ctx.jobs.start()`. +## Summary -## Tools +`dsh-tool-jobs` gives the agent three kind-independent tools for background work — `job_output`, `job_list`, and `job_kill` — so any job the agent started, whether a background command, a PTY send, or a subagent, is read, listed, and cancelled through the same controls. When a job finishes, the owning agent is told in-session: a busy agent gets the notice in its next step, an idle agent is woken with a follow-up turn, bounded per owner. Loading the plugin also attaches the job controller that lets producers start background work. The tools are generic UI cards over `ctx.jobs`; configuration tunes wait timeouts and completion delivery. -- `job_output(job_id, wait?, timeout_ms?)` reads without blocking by default. Stream jobs return only the next delta; final-output jobs return their result after settlement. Every response ends with `[status: ...]`. `wait: true` waits up to the configured cap and leaves a still-running job alive on timeout. -- `job_list()` returns caller-visible jobs as ` [] —